feat..去除了冗余的字体文件,又修改了PLONDS系统

This commit is contained in:
lincube
2026-05-30 11:56:50 +08:00
parent d004088601
commit 6a650873bc
61 changed files with 1849 additions and 3147 deletions

View File

@@ -1,4 +1,4 @@
name: PLONDS Comparator
name: PLONDS Comparator
concurrency:
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
@@ -9,7 +9,6 @@ on:
types:
- published
- prereleased
- edited
workflow_dispatch:
inputs:
tag:
@@ -17,7 +16,7 @@ on:
required: true
type: string
baseline_tag:
description: 'Optional baseline tag'
description: 'Optional baseline tag (auto-detected if omitted)'
required: false
type: string
channel:
@@ -28,12 +27,28 @@ on:
options:
- stable
- preview
compare_method:
description: '比较方法'
required: false
type: choice
default: file-compare
options:
- file-compare
- commit-analyze
hash_algorithm:
description: '哈希算法(仅 file-compare 模式)'
required: false
type: choice
default: sha256
options:
- sha256
- md5
env:
DOTNET_VERSION: '10.0.x'
jobs:
build:
compare:
runs-on: ubuntu-latest
permissions:
contents: write
@@ -48,6 +63,7 @@ jobs:
- name: Resolve release context
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
@@ -55,7 +71,9 @@ jobs:
else
CHANNEL="stable"
fi
BASELINE_TAG=""
BASELINE_TAG_INPUT=""
COMPARE_METHOD="file-compare"
HASH_ALGORITHM="sha256"
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
@@ -64,18 +82,17 @@ jobs:
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
BASELINE_TAG_INPUT="${{ github.event.inputs.baseline_tag }}"
COMPARE_METHOD="${{ github.event.inputs.compare_method }}"
HASH_ALGORITHM="${{ github.event.inputs.hash_algorithm }}"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
if [[ -z "$PUBLIC_BASE" ]]; then
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
fi
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG_INPUT}" >> "$GITHUB_ENV"
echo "COMPARE_METHOD=${COMPARE_METHOD}" >> "$GITHUB_ENV"
echo "HASH_ALGORITHM=${HASH_ALGORITHM}" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -83,181 +100,153 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Resolve baseline plan
- name: Resolve baseline
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
shell: bash
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$tag = $env:RELEASE_TAG
$baselineInput = $env:BASELINE_TAG_INPUT
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
set -euo pipefail
BASELINE_TAG=""
BASELINE_VERSION=""
$entries = foreach ($platform in $platforms) {
$assetName = "files-$platform.zip"
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if (-not $currentAsset) {
throw "Current release $tag does not contain required asset $assetName"
}
if [[ -n "$BASELINE_TAG_INPUT" ]]; then
NORMALIZED="$BASELINE_TAG_INPUT"
if [[ "$NORMALIZED" != v* ]]; then NORMALIZED="v$NORMALIZED"; fi
if gh release view "$NORMALIZED" --repo "${{ github.repository }}" --json tagName >/dev/null 2>&1; then
BASELINE_TAG="$NORMALIZED"
BASELINE_VERSION="${NORMALIZED#v}"
else
echo "Specified baseline tag not found: $NORMALIZED"
exit 1
fi
else
IS_PRERELEASE="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
CANDIDATES="$(gh api "repos/${{ github.repository }}/releases?per_page=50" \
--jq ".[] | select(.draft == false and .prerelease == ${IS_PRERELEASE} and .tag_name != \"${RELEASE_TAG}\") | .tag_name")"
$baselineRelease = $null
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
if (-not $baselineRelease) {
throw "Specified baseline tag not found: $normalizedBaseline"
}
}
else {
$baselineRelease = $allReleases |
Where-Object {
$_.tag_name -ne $tag -and
-not $_.draft -and
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
} |
Select-Object -First 1
}
for CANDIDATE in $CANDIDATES; do
if gh release download "$CANDIDATE" -p "files-windows-x64.zip" -D /tmp/baseline-check --clobber 2>/dev/null; then
BASELINE_TAG="$CANDIDATE"
BASELINE_VERSION="${CANDIDATE#v}"
rm -rf /tmp/baseline-check
break
fi
done
fi
[pscustomobject]@{
platform = $platform
assetName = $assetName
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
isFullPayload = -not $baselineRelease
}
}
$plan = [pscustomobject]@{
tag = $tag
version = $env:RELEASE_VERSION
channel = $env:RELEASE_CHANNEL
platforms = $entries
}
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
Get-Content plonds-plan.json
if [[ -n "$BASELINE_TAG" ]]; then
echo "BASELINE_TAG=${BASELINE_TAG}" >> "$GITHUB_ENV"
echo "BASELINE_VERSION=${BASELINE_VERSION}" >> "$GITHUB_ENV"
echo "Resolved baseline: ${BASELINE_TAG}"
else
echo "No baseline found. This will be a full update."
fi
- name: Download payload zips
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
shell: bash
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
set -euo pipefail
mkdir -p plonds-input
foreach ($entry in $plan.platforms) {
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
gh release download "$RELEASE_TAG" -p "files-windows-x64.zip" -D plonds-input
mv plonds-input/files-windows-x64.zip plonds-input/current-files-windows-x64.zip
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
}
}
if [[ -n "$BASELINE_TAG" ]]; then
gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D /tmp/baseline-dl
mv /tmp/baseline-dl/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip
fi
- name: Build delta assets
shell: pwsh
- name: Run build-delta (file-compare)
if: env.COMPARE_METHOD == 'file-compare'
shell: bash
run: |
$ErrorActionPreference = 'Stop'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
$args = @(
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
'build-delta',
'--platform', $entry.platform,
'--current-version', $plan.version,
'--current-tag', $plan.tag,
'--current-zip', $currentZip,
'--output-dir', 'plonds-output',
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
'--channel', $plan.channel,
'--static-output-dir', 'plonds-output/static',
'--update-base-url', $env:S3_PUBLIC_BASE_URL
set -euo pipefail
mkdir -p plonds-output
ARGS=(
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
'--configuration' 'Release' '--'
'build-delta'
'--platform' 'windows-x64'
'--current-version' "$RELEASE_VERSION"
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
'--output-dir' "$PWD/plonds-output"
'--channel' "$RELEASE_CHANNEL"
'--hash-algorithm' "$HASH_ALGORITHM"
)
if [[ -n "$BASELINE_TAG" ]]; then
ARGS+=(
'--baseline-version' "$BASELINE_VERSION"
'--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
)
fi
if ([bool]$entry.isFullPayload) {
$args += @('--is-full-payload', 'true')
}
else {
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
}
dotnet "${ARGS[@]}"
dotnet @args
}
- name: Run build-delta-from-commits (commit-analyze)
if: env.COMPARE_METHOD == 'commit-analyze'
shell: bash
run: |
set -euo pipefail
mkdir -p plonds-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
build-index `
--release-tag $plan.tag `
--version $plan.version `
--channel $plan.channel `
--platform-summaries-dir plonds-output/platform-summaries `
--output-dir plonds-output `
--private-key $env:UPDATE_PRIVATE_KEY_PATH
ARGS=(
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
'--configuration' 'Release' '--'
'build-delta-from-commits'
'--platform' 'windows-x64'
'--current-version' "$RELEASE_VERSION"
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
'--output-dir' "$PWD/plonds-output"
'--channel' "$RELEASE_CHANNEL"
'--baseline-tag' "${BASELINE_TAG:-v0.0.0}"
'--current-tag' "$RELEASE_TAG"
'--hash-algorithm' "$HASH_ALGORITHM"
)
foreach ($entry in $plan.platforms) {
$summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json
$required = @(
"plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json",
"plonds-output/static/meta/distributions/$($summary.distributionId).json",
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
if [[ -n "$BASELINE_TAG" ]]; then
ARGS+=(
'--baseline-version' "$BASELINE_VERSION"
'--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
)
fi
foreach ($path in $required) {
if (-not (Test-Path $path)) {
throw "Missing PLONDS static output: $path"
}
}
}
dotnet "${ARGS[@]}"
$objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue
if (-not $objects -or $objects.Count -eq 0) {
throw "PLONDS static object repository is empty."
}
- name: Validate output
shell: bash
run: |
set -euo pipefail
if [[ ! -f plonds-output/changed.zip ]]; then
echo "Missing output: changed.zip"
exit 1
fi
if [[ ! -f plonds-output/PLONDS.json ]]; then
echo "Missing output: PLONDS.json"
exit 1
fi
jq -e . plonds-output/PLONDS.json >/dev/null
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
- name: Upload PLONDS assets to release
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
gh release upload "$RELEASE_TAG" plonds-output/changed.zip plonds-output/PLONDS.json --clobber
- name: Persist run metadata
shell: bash
run: |
mkdir -p plonds-run-metadata
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
printf '%s' "$COMPARE_METHOD" >> plonds-run-metadata/tag.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
@@ -266,11 +255,3 @@ jobs:
path: plonds-run-metadata/tag.txt
if-no-files-found: error
retention-days: 7
- name: Upload PLONDS static artifact
uses: actions/upload-artifact@v4
with:
name: plonds-static
path: plonds-output/static/**
if-no-files-found: error
retention-days: 7

View File

@@ -0,0 +1,512 @@
# PLONDS Comparator 改造设计
> 日期2026-05-30
> 状态:待审批
## 1. 背景与动机
PLONDSPenguin Logistics Online Network Distribution System是 LanMountainDesktop 的文件驱动式分布式更新系统。当前 Comparator 工作流存在以下问题:
1. **产出物过于复杂**:生成 `update-{platform}.zip``plonds-filemap-{platform}.json``plonds-filemap-{platform}.json.sig``platform-summary-{platform}.json``plonds-static.zip` 等多个文件,客户端消费困难
2. **模型定义重复**`Plonds.Shared``Plonds.Core`、宿主侧、Launcher 侧各自定义独立的 DTO字段名不一致
3. **签名机制过重**RSA 签名增加了 CI 复杂度(需要管理密钥),且对文件驱动式更新系统而言 SHA256 哈希校验已足够
4. **平台覆盖不当**Linux 平台不需要 PLONDS 支持macOS 尚未接入,但代码中硬编码了三个平台
5. **工作流间 artifact 传递脆弱**Comparator → Publisher 通过 artifact 传递 `plonds-static.zip`,容易断裂
## 2. 设计目标
- 产出物精简为两个文件:`changed.zip` + `PLONDS.json`
- 去掉 RSA 签名,只用 SHA256/MD5 校验
- 只关注 Windows 平台
- 统一模型定义,消除 DTO 重复
- 保持 Comparator 和 Publisher 两个工作流的职责分离
## 3. 新产出物定义
### 3.1 changed.zip
只包含与上一版本有差异的文件action 为 `add``replace` 的文件),目录结构与部署目录一致。
### 3.2 PLONDS.json
```json
{
"formatVersion": "2.0",
"currentVersion": "1.2.0",
"previousVersion": "1.1.0",
"isFullUpdate": false,
"requiresCleanInstall": false,
"channel": "stable",
"platform": "windows-x64",
"updatedAt": "2026-05-30T12:00:00Z",
"filesMap": {
"LanMountainDesktop.exe": {
"action": "replace",
"sha256": "abc123...",
"size": 1024000
},
"LanMountainDesktop.dll": {
"action": "reuse",
"sha256": "def456...",
"size": 512000
},
"OldModule.dll": {
"action": "delete",
"sha256": "",
"size": 0
}
},
"changedFilesMap": {
"LanMountainDesktop.exe": {
"archivePath": "LanMountainDesktop.exe",
"sha256": "abc123...",
"size": 1024000
}
},
"checksums": {
"changed.zip": "md5:9a8b7c6d..."
}
}
```
### 3.3 字段语义
| 字段 | 类型 | 说明 |
|------|------|------|
| `formatVersion` | string | 协议版本,固定 `"2.0"` |
| `currentVersion` | string | 当前发布版本 |
| `previousVersion` | string | 基线版本(全量更新时为 `"0.0.0"` |
| `isFullUpdate` | bool | 是否为全量更新(找不到基线版本时为 true |
| `requiresCleanInstall` | bool | 启动器是否也更新了——如果是,客户端不走增量流程,让用户重新运行安装器 |
| `channel` | string | 更新通道:`stable``preview` |
| `platform` | string | 平台标识:`windows-x64` |
| `updatedAt` | string | ISO 8601 时间戳 |
| `filesMap` | object | 全量文件图:每个文件的 action + sha256 + size |
| `changedFilesMap` | object | 变更文件图:只包含需要从 changed.zip 解压的文件 |
| `checksums` | object | 产出物的 MD5 值 |
### 3.4 filesMap 中 action 的值
| Action | 含义 | changed.zip 中是否包含 |
|--------|------|----------------------|
| `add` | 新增文件 | ✅ |
| `replace` | 替换文件 | ✅ |
| `reuse` | 复用上一版本文件 | ❌ |
| `delete` | 删除文件 | ❌ |
### 3.5 requiresCleanInstall 判断逻辑
比较 `LanMountainDesktop.Launcher.exe` 在当前版本和基线版本中的 SHA256
- 如果 SHA256 不同 → `requiresCleanInstall = true`
- 如果 SHA256 相同或没有基线版本 → `requiresCleanInstall = false`
## 4. Plonds.Tool build-delta 命令改造
### 4.1 新命令签名
```
build-delta --platform <platform>
--current-version <version>
--current-zip <file>
--output-dir <dir>
--channel <channel>
[--baseline-version <version>]
[--baseline-zip <file>]
[--launcher-path <relative-path>]
```
### 4.2 参数说明
| 参数 | 必需 | 说明 |
|------|------|------|
| `--platform` | 是 | 平台标识,如 `windows-x64` |
| `--current-version` | 是 | 当前发布版本号 |
| `--current-zip` | 是 | 当前版本的 payload zip 路径 |
| `--output-dir` | 是 | 输出目录 |
| `--channel` | 是 | 更新通道 |
| `--baseline-version` | 否 | 基线版本号(省略则视为全量更新) |
| `--baseline-zip` | 否 | 基线版本的 payload zip 路径(省略则视为全量更新) |
| `--launcher-path` | 否 | Launcher 可执行文件的相对路径,默认 `LanMountainDesktop.Launcher.exe` |
### 4.3 删除的参数
| 参数 | 原因 |
|------|------|
| `--current-tag` | 不再需要version 就够了 |
| `--private-key` | 去掉签名 |
| `--is-full-payload` | 自动判断:没有 baseline-zip 就是全量 |
| `--static-output-dir` | 不再生成 S3 静态布局 |
| `--update-base-url` | 不再生成 S3 URL |
| `--baseline-tag` | 不再需要 |
### 4.4 内部逻辑
```
1. 解压 current-zip → currentDir
2. 如果有 baseline-zip → 解压 → baselineDir
否则 → baselineDir = 空(全量更新)
3. 扫描 currentDir → 计算 SHA256
4. 扫描 baselineDir → 计算 SHA256如果有
5. 对比生成 filesMap:
- 两个版本都有且 SHA256 相同 → reuse
- 两个版本都有但 SHA256 不同 → replace
- 只在新版本中存在 → add
- 只在旧版本中存在 → delete
6. 从 filesMap 提取 changedFilesMap:
- 只包含 action=add/replace 的条目
- 添加 archivePath在 changed.zip 中的路径)
7. 打包 changed.zip:
- 只包含 add/replace 的文件
- 保持原始目录结构
8. 判断 requiresCleanInstall:
- 比较 Launcher 可执行文件在两个版本中的 SHA256
- 如果不同 → requiresCleanInstall=true
9. 计算 changed.zip 的 MD5
10. 生成 PLONDS.json
11. 输出到 output-dir:
- changed.zip
- PLONDS.json
```
### 4.5 不再生成的产物
| 旧产物 | 处置 |
|--------|------|
| `update-{platform}.zip` | 被 `changed.zip` 替代 |
| `plonds-filemap-{platform}.json` | 被 `PLONDS.json` 替代 |
| `plonds-filemap-{platform}.json.sig` | 去掉签名 |
| `platform-summary-{platform}.json` | 不再需要 |
| `plonds-static.zip` | 不再生成 S3 静态布局 |
| `meta/channels/...` | 不再由 Tool 生成,由 Publisher 负责 |
## 5. Plonds.Shared 模型改造
### 5.1 删除的模型
| 模型 | 原因 |
|------|------|
| `PlondsFileMap` | 被新的 `PlondsManifest` 替代 |
| `PlondsFileEntry` | 被新的 `PlondsFileEntry` 替代 |
| `PlondsComponent` | 不再有组件概念 |
| `PlondsDistributionInfo` | 不再生成分发文档 |
| `PlondsChannelPointer` | 由 Publisher 用脚本生成 |
| `PlondsReleaseManifest` | 不再需要 |
| `PlondsReleasePlatformEntry` | 不再需要 |
| `PlondsSignatureDescriptor` | 去掉签名 |
| `PlondsMirrorAsset` | 由 Publisher 处理 |
| `PlondsMirrorEntry` | 由 Publisher 处理 |
| `PlondsMetadataCatalog` | 不再需要 |
| `PlondsAssetEntry` | 不再需要 |
### 5.2 新模型定义
```csharp
// PlondsManifest — 对应 PLONDS.json
public sealed record PlondsManifest(
string FormatVersion,
string CurrentVersion,
string PreviousVersion,
bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums);
// PlondsFileEntry — filesMap 中的条目
public sealed record PlondsFileEntry(
string Action, // add | replace | reuse | delete
string Sha256,
long Size);
// PlondsChangedFileEntry — changedFilesMap 中的条目
public sealed record PlondsChangedFileEntry(
string ArchivePath, // 在 changed.zip 中的路径
string Sha256,
long Size);
```
### 5.3 设计决策
- `FilesMap``ChangedFilesMap``IReadOnlyDictionary<string, T>` 而非 `IReadOnlyList<T>`key 就是文件相对路径,查找 O(1)
- 去掉 `Component` 概念——当前只有一个 `app` 组件,分层没有实际意义
- `FormatVersion` 固定为 `"2.0"`,与旧格式区分
## 6. Comparator 工作流改造
### 6.1 保留两个工作流
- **Comparator**`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
- **Publisher**`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
### 6.2 Comparator 改造后步骤
```yaml
# plonds-comparator.yml
触发: release.published / release.prereleased / workflow_dispatch
jobs:
compare:
runs-on: ubuntu-latest
steps:
- Checkout
- 解析发布上下文
→ RELEASE_TAG, RELEASE_VERSION, RELEASE_CHANNEL
- Setup .NET
- 构建 PLONDS Tool
- 解析基线版本
→ 查找上一个同频道 Release
→ 如果有 → 记录 baseline_tag, baseline_version
→ 如果没有 → is_full_update=true
- 下载 payload zips
→ 下载当前版本 files-windows-x64.zip
→ 下载基线版本 files-windows-x64.zip (如果有)
- 运行 build-delta
→ dotnet run Plonds.Tool -- build-delta \
--platform windows-x64 \
--current-version $VERSION \
--current-zip files-windows-x64.zip \
--output-dir plonds-output \
--channel $CHANNEL \
[--baseline-version $BASELINE_VERSION] \
[--baseline-zip baseline-files-windows-x64.zip]
- 上传到 GitHub Release
→ gh release upload changed.zip PLONDS.json
- 传递元数据给 Publisher
→ 上传 artifact: plonds-run-metadata (tag.txt)
```
### 6.3 与当前步骤的差异
| 当前步骤 | 改造后 |
|---------|--------|
| 准备签名密钥 | ❌ 删除 |
| 解析基线计划 (pwsh三平台) | ✅ 简化:只找 Windows逻辑简化 |
| 下载 payload zips (pwsh三平台) | ✅ 简化:只下载 Windows |
| 构建增量资产 (pwsh含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
## 7. 双模式差分生成
### 7.1 概述
Comparator 支持两种差分生成方法,通过 `workflow_dispatch``compare_method` 输入项选择:
| 方法 | 标识 | 核心思路 |
|------|------|---------|
| 方法一 | `file-compare` | 下载两个版本的 files zip全量文件哈希对比 |
| 方法二 | `commit-analyze` | 分析两个版本之间的 git commit映射源码变更到产物文件 |
### 7.2 GitHub Actions 触发器新增输入项
```yaml
workflow_dispatch:
inputs:
tag: ...
baseline_tag: ...
channel: ...
compare_method: # 新增
description: '比较方法'
type: choice
default: file-compare
options:
- file-compare
- commit-analyze
hash_algorithm: # 新增(仅方法一)
description: '哈希算法'
type: choice
default: sha256
options:
- sha256
- md5
```
当由 `release` 事件触发时,默认使用 `file-compare` + `sha256`
### 7.3 方法一文件对比模式file-compare
**流程:**
```
1. 下载当前版本 files-windows-x64.zip
2. 下载基线版本 files-windows-x64.zip如果有
3. 解压两个 zip 到临时目录
4. 用指定哈希算法sha256/md5扫描两个目录的所有文件
5. 对比哈希值,生成 filesMapadd/replace/reuse/delete
6. 从当前版本目录中提取 add/replace 的文件 → changed.zip
7. 生成 PLONDS.json
```
**PlondsDeltaBuildOptions 新增参数:**
```csharp
string HashAlgorithm = "sha256" // "sha256" | "md5"
```
**哈希算法对 PLONDS.json 的影响:**
- `sha256``filesMap``changedFilesMap` 中使用 `sha256` 字段
- `md5``filesMap``changedFilesMap` 中使用 `md5` 字段
### 7.4 方法二Commit 分析模式commit-analyze
**流程:**
```
1. 下载当前版本 files-windows-x64.zip
2. 解压到临时目录
3. git log --name-only baseline_tag..current_tag
→ 得到两个版本之间的 commit 列表和涉及的源码文件
4. 过滤:只保留源码目录下的文件
5. 用简单规则映射源码文件到产物文件
6. 从当前版本的解压目录中提取映射到的产物文件 → changed.zip
7. 生成 PLONDS.json
8. 如果没有源码变更 → 自动回退到方法一
```
**源码目录过滤规则:**
只分析以下目录下的文件变更:
| 目录 | 说明 |
|------|------|
| `LanMountainDesktop/` | 主宿主应用 |
| `LanMountainDesktop.Launcher/` | 启动器 |
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 |
| `LanMountainDesktop.PluginSdk/` | 插件 SDK |
| `LanMountainDesktop.Appearance/` | 外观系统 |
| `LanMountainDesktop.Settings.Core/` | 设置核心 |
| `LanMountainDesktop.ComponentSystem/` | 组件系统 |
忽略的目录:`docs/``scripts/``.github/``.trae/``PenguinLogisticsOnlineNetworkDistributionSystem/`
**源码到产物的映射规则:**
| 源码路径模式 | 映射到产物文件 |
|-------------|--------------|
| `LanMountainDesktop/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.dll`, `LanMountainDesktop.exe` |
| `LanMountainDesktop.Launcher/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.Launcher.exe` |
| `LanMountainDesktop.Shared.Contracts/**/*.cs` | `LanMountainDesktop.Shared.Contracts.dll` |
| `LanMountainDesktop.PluginSdk/**/*.cs` | `LanMountainDesktop.PluginSdk.dll` |
| `LanMountainDesktop.Appearance/**/*.cs` | `LanMountainDesktop.Appearance.dll` |
| `LanMountainDesktop.Settings.Core/**/*.cs` | `LanMountainDesktop.Settings.Core.dll` |
| `LanMountainDesktop.ComponentSystem/**/*.cs` | `LanMountainDesktop.ComponentSystem.dll` |
| `**/*.json`(配置文件) | 同路径的 .json |
| 其他无法映射的变更 | 保守标记 → 所有核心 .dll/.exe |
**方法二在 Plonds.Tool 中的新命令:**
```
build-delta-from-commits --platform <platform>
--current-version <version>
--current-zip <file>
--output-dir <dir>
--channel <channel>
--baseline-tag <tag>
--current-tag <tag>
[--source-dirs <dir1,dir2,...>]
[--fallback-zip <file>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--platform` | 是 | 平台标识 |
| `--current-version` | 是 | 当前发布版本号 |
| `--current-zip` | 是 | 当前版本的 payload zip |
| `--output-dir` | 是 | 输出目录 |
| `--channel` | 是 | 更新通道 |
| `--baseline-tag` | 是 | 基线版本的 git tag |
| `--current-tag` | 是 | 当前版本的 git tag |
| `--source-dirs` | 否 | 要分析的源码目录列表(逗号分隔) |
| `--fallback-zip` | 否 | 回退到方法一时使用的基线 zip |
**回退逻辑:**
如果 `git log` 分析后发现没有源码目录下的文件变更(比如只有 docs/ 变更),则自动回退到方法一:
1. 如果提供了 `--fallback-zip` → 用方法一对比两个 zip
2. 如果没有提供 → 生成全量更新(`isFullUpdate=true`
### 7.5 方法二的 PLONDS.json 特殊处理
方法二无法像方法一那样生成完整的 `filesMap`(因为不知道哪些文件是 reuse 的),因此:
- `filesMap` 只包含映射到的变更文件(标记为 `add``replace`
- 不包含 `reuse``delete` 条目
- `isFullUpdate` 始终为 `false`(除非回退到方法一且无基线)
- `requiresCleanInstall` 根据 Launcher.exe 是否在映射到的变更文件列表中判断
### 7.6 工作流中的条件分支
```yaml
- name: Run build-delta
shell: bash
run: |
if [[ "$COMPARE_METHOD" == "commit-analyze" ]]; then
# 方法二
dotnet run --project ... -- build-delta-from-commits \
--platform windows-x64 \
--current-version $RELEASE_VERSION \
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
--output-dir $PWD/plonds-output \
--channel $RELEASE_CHANNEL \
--baseline-tag $BASELINE_TAG \
--current-tag $RELEASE_TAG \
--fallback-zip $PWD/plonds-input/baseline-files-windows-x64.zip
else
# 方法一
dotnet run --project ... -- build-delta \
--platform windows-x64 \
--current-version $RELEASE_VERSION \
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
--output-dir $PWD/plonds-output \
--channel $RELEASE_CHANNEL \
--hash-algorithm $HASH_ALGORITHM \
--baseline-version $BASELINE_VERSION \
--baseline-zip $PWD/plonds-input/baseline-files-windows-x64.zip
fi
```
方法二时,基线 zip 仍然需要下载(用于回退),但不需要解压(除非回退)。
### 7.7 两种方法的步骤差异
| 步骤 | 方法一 (file-compare) | 方法二 (commit-analyze) |
|------|----------------------|------------------------|
| 下载基线 zip | ✅ 需要 | ✅ 需要(用于回退) |
| 下载当前 zip | ✅ | ✅ |
| 解压两个 zip | ✅ | ✅ 只解压当前(回退时解压基线) |
| git diff/log | ❌ | ✅ 需要 fetch-depth:0 |
| 哈希对比 | ✅ 两个目录全量扫描 | ❌ 不做(除非回退) |
| 源码→产物映射 | ❌ | ✅ |
| 回退逻辑 | ❌ | ✅ 无源码变更时回退方法一 |
## 8. 不在本次改造范围内的事项
- Publisher 工作流改造(后续单独设计)
- Rollback 工作流改造(后续单独设计)
- 宿主侧客户端代码改造PlondsUpdateApplier 等,后续单独设计)
- Launcher 侧客户端代码改造(后续单独设计)
- Plonds.Api 项目处置(后续决定是否保留)
- `build-index``build-plonds``generate``publish``sign``pack-payload` 等 Tool 命令的清理(后续处理)

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnetCampus.Ipc" />
</ItemGroup>
</Project>

10
CheckIpcAot/Program.cs Normal file
View File

@@ -0,0 +1,10 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
using System.Threading.Tasks;
[IpcPublic]
public interface IMyService {
Task<MyResult> DoWork(MyRequest req);
}
public class MyResult { public string Msg {get;set;} }
public class MyRequest { public string Data {get;set;} }

View File

@@ -39,4 +39,9 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PrivacyAgreementState))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
[JsonSerializable(typeof(AirAppOpenRequest))]
[JsonSerializable(typeof(AirAppRegistrationRequest))]
[JsonSerializable(typeof(AirAppInstanceInfo))]
[JsonSerializable(typeof(AirAppOperationResult))]
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -52,6 +52,7 @@
</ItemGroup>
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 忽略某些警告 -->
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
@@ -60,7 +61,8 @@
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- [Fix]: 必须设置为 true 以支持 dotnetCampus.Ipc 内部的反射序列化。相关类型的剪裁保护通过 AppJsonContext 保证 -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>

View File

@@ -146,15 +146,10 @@ public sealed class WindowLayerIsolationTests
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
{
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs");
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
Assert.Contains("SendToBottom", desktopWidgetWindow);
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow);
Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow);
Assert.Contains("SendToBottom", transparentOverlayWindow);
}
[Fact]

View File

@@ -75,9 +75,7 @@ public partial class App : Application
private DispatcherTimer? _shellRecoveryTimer;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow;
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
private bool _isExitingFusedDesktopEditMode;
private bool _mainWindowClosed;
private DesktopShellHost? _desktopShellHost;
private PublicIpcHostService? _publicIpcHostService;
@@ -454,22 +452,10 @@ public partial class App : Application
try
{
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate();
fusedDesktopManager.EnterEditMode();
EnsureTransparentOverlayWindow();
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Show();
}
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
if (_fusedComponentLibraryWindow is { } existingWindow)
{
if (_transparentOverlayWindow is not null)
{
existingWindow.SetOverlayWindow(_transparentOverlayWindow);
}
if (!existingWindow.IsVisible)
{
existingWindow.Show();
@@ -477,7 +463,7 @@ public partial class App : Application
if (centerInWorkArea)
{
existingWindow.CenterInWorkArea(_transparentOverlayWindow);
existingWindow.CenterInWorkArea();
}
existingWindow.Activate();
@@ -486,16 +472,12 @@ public partial class App : Application
var window = new FusedDesktopComponentLibraryWindow();
_fusedComponentLibraryWindow = window;
if (_transparentOverlayWindow is not null)
{
window.SetOverlayWindow(_transparentOverlayWindow);
}
window.Closed += OnFusedComponentLibraryWindowClosed;
window.Show();
if (centerInWorkArea)
{
window.CenterInWorkArea(_transparentOverlayWindow);
window.CenterInWorkArea();
}
window.Activate();
@@ -503,7 +485,13 @@ public partial class App : Application
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
if (_fusedComponentLibraryWindow is { } libWindow)
{
_fusedComponentLibraryWindow = null;
libWindow.Closed -= OnFusedComponentLibraryWindowClosed;
libWindow.Close();
}
}
}
@@ -520,50 +508,13 @@ public partial class App : Application
_fusedComponentLibraryWindow = null;
}
if (!window.PreserveEditModeOnClose && !_isExitingFusedDesktopEditMode)
{
ExitFusedDesktopEditModeFromUi(closeLibrary: false);
}
}
private void ExitFusedDesktopEditModeFromUi(bool closeLibrary)
{
if (_isExitingFusedDesktopEditMode)
{
return;
}
_isExitingFusedDesktopEditMode = true;
try
{
if (closeLibrary && _fusedComponentLibraryWindow is { } libraryWindow)
{
_fusedComponentLibraryWindow = null;
libraryWindow.Closed -= OnFusedComponentLibraryWindowClosed;
libraryWindow.Close();
}
try
{
_transparentOverlayWindow?.SaveLayoutAndHide();
}
catch (Exception overlayEx)
{
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx);
}
try
{
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
catch (Exception exitEx)
{
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx);
}
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
finally
catch (Exception ex)
{
_isExitingFusedDesktopEditMode = false;
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode after library closed.", ex);
}
}
@@ -890,11 +841,6 @@ public partial class App : Application
{
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
@@ -938,26 +884,6 @@ public partial class App : Application
return false;
}
}
private void EnsureTransparentOverlayWindow()
{
if (_transparentOverlayWindow is null)
{
_transparentOverlayWindow = new TransparentOverlayWindow();
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
{
RestoreOrCreateMainWindow("TransparentOverlay");
};
_transparentOverlayWindow.ExitEditRequested += (s, e) =>
{
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
};
_transparentOverlayWindow.RestoreComponentLibraryRequested += (s, e) =>
{
OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: true);
};
}
}
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
{
@@ -1263,31 +1189,16 @@ public partial class App : Application
finally
{
_fusedComponentLibraryWindow = null;
try
{
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex);
}
}
}
if (_transparentOverlayWindow is not null)
try
{
try
{
_transparentOverlayWindow.Close();
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
}
finally
{
_transparentOverlayWindow = null;
}
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to shut down fused desktop manager during exit cleanup.", ex);
}
AudioRecorderServiceFactory.DisposeSharedServices();
@@ -1572,13 +1483,6 @@ public partial class App : Application
AppLogger.Info(
"DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
{
EnsureTransparentOverlayWindow();
_transparentOverlayWindow?.Show();
}
}
catch (Exception ex)
{
@@ -1668,7 +1572,6 @@ public partial class App : Application
if (IsMainWindowDesktopLayerEnabled())
{
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
_mainWindow.ShowInTaskbar = false;
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
@@ -1697,7 +1600,6 @@ public partial class App : Application
return;
}
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
}
catch (Exception ex)

View File

@@ -1,35 +0,0 @@
# MiSans 字体说明
## 中文
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
### 包含文件
- `MiSans-Regular.ttf`
- `MiSans-Semibold.ttf`
- `MiSans-Bold.ttf`
### 来源
- 上游仓库https://github.com/dsrkafuu/misans
- 上游所引用的小米字体页面https://hyperos.mi.com/font/zh/
### 许可与使用说明
- 上游脚本或打包仓库使用 Apache-2.0 许可。
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
## English
This project bundles MiSans fonts for more consistent cross-device rendering.
### Sources
- Upstream package repository: https://github.com/dsrkafuu/misans
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
Please review and comply with the MiSans font terms before redistributing this application.

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
@@ -11,46 +12,46 @@ using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Services;
/// <summary>
/// 融合桌面中央管理器服务接口
/// </summary>
public interface IFusedDesktopManagerService
{
void Initialize();
void EnterEditMode();
void ExitEditMode();
void ReloadWidgets();
void Shutdown();
void AddComponent(string componentId);
void RemoveComponent(string placementId);
void EnterEditMode();
void ExitEditMode();
bool IsEditMode { get; }
}
/// <summary>
/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。
/// </summary>
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
{
private readonly IFusedDesktopLayoutService _layoutService;
private readonly ISettingsFacadeService _settingsFacade;
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
// 基础服务依赖
private readonly IWeatherInfoService _weatherDataService;
private readonly TimeZoneService _timeZoneService;
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private bool _isEditMode;
private const double DefaultCellSize = 100;
private const double DefaultComponentWidth = 200;
private const double DefaultComponentHeight = 200;
public bool IsEditMode => _isEditMode;
public FusedDesktopManagerService(
IFusedDesktopLayoutService layoutService,
IFusedDesktopLayoutService layoutService,
ISettingsFacadeService settingsFacade)
{
_layoutService = layoutService;
_settingsFacade = settingsFacade;
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
}
@@ -58,15 +59,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
public void Initialize()
{
if (!OperatingSystem.IsWindows()) return;
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (!appSnapshot.EnableFusedDesktop)
{
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
return;
}
EnsureRegistries();
ReloadWidgets();
}
@@ -74,7 +74,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
private void EnsureRegistries()
{
if (_componentRuntimeRegistry is not null) return;
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
@@ -88,12 +88,12 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (_isEditMode) return;
_isEditMode = true;
// 【修复问题3】不再隐藏窗口而是将窗口内容转移到编辑模式覆盖层
// 这样可以保持组件的运行状态(动画、输入等)
foreach (var window in _widgetWindows.Values)
{
window.Hide();
window.SetEditMode(true);
}
AppLogger.Info("FusedDesktop", "Entered edit mode.");
}
public void ExitEditMode()
@@ -101,25 +101,91 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (!_isEditMode) return;
_isEditMode = false;
// 编辑完成,重新加载布局(可能已发生更改)并显示
ReloadWidgets();
foreach (var window in _widgetWindows.Values)
{
window.SetEditMode(false);
}
AppLogger.Info("FusedDesktop", "Exited edit mode.");
}
public void AddComponent(string componentId)
{
EnsureRegistries();
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
{
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {componentId}");
return;
}
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = Guid.NewGuid().ToString("N"),
ComponentId = componentId,
Width = DefaultComponentWidth,
Height = DefaultComponentHeight
};
var screen = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
?.MainWindow?.Screens.Primary;
if (screen is not null)
{
var scaling = screen.Scaling;
var workArea = screen.WorkingArea;
placement.X = (workArea.Width / scaling - placement.Width) / 2;
placement.Y = (workArea.Height / scaling - placement.Height) / 2;
}
_layoutService.AddComponentPlacement(placement);
try
{
var window = CreateWidgetWindow(placement);
if (window != null)
{
_widgetWindows[placement.PlacementId] = window;
if (_isEditMode)
{
window.SetEditMode(true);
}
window.Show();
window.Position = new PixelPoint((int)placement.X, (int)placement.Y);
window.RefreshDesktopLayer();
}
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktopMgr", $"Failed to create widget window for {componentId}", ex);
_layoutService.RemoveComponentPlacement(placement.PlacementId);
}
AppLogger.Info("FusedDesktopMgr", $"Added component '{componentId}' with placement '{placement.PlacementId}'.");
}
public void RemoveComponent(string placementId)
{
if (_widgetWindows.Remove(placementId, out var windowToRemove))
{
windowToRemove.Close();
}
_layoutService.RemoveComponentPlacement(placementId);
AppLogger.Info("FusedDesktopMgr", $"Removed component placement '{placementId}'.");
}
public void ReloadWidgets()
{
if (_isEditMode) return; // 编辑模式下不渲染小窗口
var layout = _layoutService.Load();
var existingIds = new HashSet<string>(_widgetWindows.Keys);
foreach (var placement in layout.ComponentPlacements)
{
existingIds.Remove(placement.PlacementId);
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
{
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.Position = new PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
if (existingWindow.IsVisible == false)
{
@@ -130,15 +196,19 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
}
else
{
// 新组件,生成窗口
try
{
var window = CreateWidgetWindow(placement);
if (window != null)
{
_widgetWindows[placement.PlacementId] = window;
if (_isEditMode)
{
window.SetEditMode(true);
}
window.Show();
window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
window.Position = new PixelPoint((int)placement.X, (int)placement.Y);
window.RefreshDesktopLayer();
}
}
@@ -148,8 +218,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
}
}
}
// 移除被删除的组件
foreach (var id in existingIds)
{
if (_widgetWindows.Remove(id, out var windowToRemove))
@@ -179,7 +248,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
return null;
}
var control = descriptor.CreateControl(
DefaultCellSize,
_timeZoneService,
@@ -188,28 +257,24 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
_calculatorDataService,
_settingsFacade,
placement.PlacementId);
// 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度)
control.Width = placement.Width;
control.Height = placement.Height;
var window = new DesktopWidgetWindow(control);
var window = new DesktopWidgetWindow(control, placement.PlacementId);
return window;
}
}
/// <summary>
/// 工厂
/// </summary>
public static class FusedDesktopManagerServiceFactory
{
private static IFusedDesktopManagerService? _instance;
private static readonly object _lock = new();
public static IFusedDesktopManagerService GetOrCreate()
{
if (_instance is not null) return _instance;
lock (_lock)
{
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using LanMountainDesktop.Services;
@@ -12,6 +13,13 @@ public partial class DesktopWidgetWindow : Window
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
private bool _isEditMode;
private bool _isDragging;
private PixelPoint _dragStartWindowPosition;
private Point _dragStartPointerPosition;
public string? PlacementId { get; }
public DesktopWidgetWindow()
{
InitializeComponent();
@@ -23,11 +31,34 @@ public partial class DesktopWidgetWindow : Window
}
}
public DesktopWidgetWindow(Control componentContent) : this()
public DesktopWidgetWindow(Control componentContent, string? placementId = null) : this()
{
PlacementId = placementId;
ComponentContainer.Child = componentContent;
}
public void SetEditMode(bool editMode)
{
if (_isEditMode == editMode) return;
_isEditMode = editMode;
if (ComponentContainer.Child is Control child)
{
child.IsHitTestVisible = !editMode;
}
if (editMode)
{
Cursor = new Cursor(StandardCursorType.SizeAll);
}
else
{
Cursor = null;
}
AppLogger.Info("DesktopWidgetWindow", $"Edit mode set to {editMode}. PlacementId='{PlacementId}'.");
}
public void UpdateComponentLayout(double width, double height)
{
ComponentContainer.Width = width;
@@ -74,6 +105,109 @@ public partial class DesktopWidgetWindow : Window
}
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
if (_isEditMode && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
BeginDrag(e);
e.Handled = true;
return;
}
if (!_isEditMode && e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
ShowContextMenu(e);
e.Handled = true;
return;
}
base.OnPointerPressed(e);
}
protected override void OnPointerMoved(PointerEventArgs e)
{
if (_isDragging)
{
var currentPointer = e.GetPosition(this);
var delta = currentPointer - _dragStartPointerPosition;
Position = new PixelPoint(
_dragStartWindowPosition.X + (int)delta.X,
_dragStartWindowPosition.Y + (int)delta.Y);
e.Handled = true;
return;
}
base.OnPointerMoved(e);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
if (_isDragging)
{
EndDrag();
e.Handled = true;
return;
}
base.OnPointerReleased(e);
}
private void BeginDrag(PointerPressedEventArgs e)
{
_isDragging = true;
_dragStartWindowPosition = Position;
_dragStartPointerPosition = e.GetPosition(this);
e.Pointer.Capture(this);
}
private void EndDrag()
{
_isDragging = false;
if (PlacementId is not null)
{
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
var layout = layoutService.Load();
var placement = layout.ComponentPlacements.Find(
p => string.Equals(p.PlacementId, PlacementId, StringComparison.OrdinalIgnoreCase));
if (placement is not null)
{
placement.X = Position.X;
placement.Y = Position.Y;
layoutService.Save(layout);
}
}
RefreshDesktopLayer();
}
private void ShowContextMenu(PointerPressedEventArgs e)
{
var removeItem = new MenuItem
{
Header = "移除组件"
};
removeItem.Click += (_, _) =>
{
if (PlacementId is not null)
{
FusedDesktopManagerServiceFactory.GetOrCreate().RemoveComponent(PlacementId);
}
else
{
Close();
}
};
var menu = new ContextMenu
{
Items = { removeItem }
};
menu.Open(this);
}
private void UpdateInteractiveRegion()
{
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>

View File

@@ -12,8 +12,6 @@ namespace LanMountainDesktop.Views;
public partial class FusedDesktopComponentLibraryWindow : Window
{
private TransparentOverlayWindow? _overlayWindow;
public FusedDesktopComponentLibraryWindow()
{
InitializeComponent();
@@ -45,13 +43,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window
RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component;
}
public bool PreserveEditModeOnClose { get; private set; }
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
{
_overlayWindow = overlayWindow;
}
public void CenterInWorkArea(Window? referenceWindow = null)
{
var screen = referenceWindow is not null
@@ -74,22 +65,13 @@ public partial class FusedDesktopComponentLibraryWindow : Window
private void OnAddComponentRequested(object? sender, string componentId)
{
if (_overlayWindow is null)
{
AppLogger.Warn("FusedDesktopLibrary", "Overlay window is not set.");
return;
}
_overlayWindow.AddComponentToCenter(componentId);
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' at fused desktop grid center.");
PreserveEditModeOnClose = true;
FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId);
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' directly to fused desktop.");
Close();
}
private void OnCloseClick(object? sender, RoutedEventArgs e)
{
PreserveEditModeOnClose = false;
Close();
}
@@ -105,7 +87,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window
{
if (e.Key == Key.Escape)
{
PreserveEditModeOnClose = false;
Close();
}
}

View File

@@ -1,102 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.TransparentOverlayWindow"
WindowDecorations="None"
CanResize="False"
ShowInTaskbar="False"
ExtendClientAreaToDecorationsHint="True"
TransparencyLevelHint="Transparent"
Background="{x:Null}"
Title="LanMountainDesktop Fused Desktop">
<Window.Styles>
<Style Selector="Border.fused-desktop-component-host">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
</Style>
<Style Selector="Border.fused-desktop-component-host.selected">
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
<Style Selector="Border.fused-desktop-resize-handle">
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="Border.fused-desktop-edit-toolbar">
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
<Setter Property="BoxShadow" Value="0 8 32 #33000000" />
</Style>
<Style Selector="Button.edit-toolbar-button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="14,8" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button.edit-toolbar-button:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}" />
<Setter Property="RenderTransform" Value="scale(1.02)" />
</Style>
<Style Selector="Button.edit-toolbar-button:pressed">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonPressedBackgroundBrush}" />
<Setter Property="RenderTransform" Value="scale(0.98)" />
</Style>
<Style Selector="Button.edit-toolbar-button fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.edit-toolbar-separator">
<Setter Property="Width" Value="1" />
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="Margin" Value="4,8" />
<Setter Property="Opacity" Value="0.5" />
</Style>
</Window.Styles>
<Grid x:Name="OverlayRoot"
Background="{x:Null}">
<Canvas x:Name="ComponentCanvas"
PointerPressed="OnCanvasPointerPressed" />
<Border x:Name="EditToolbar"
Classes="fused-desktop-edit-toolbar"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,20"
Padding="6"
IsHitTestVisible="True">
<StackPanel Orientation="Horizontal" Spacing="2">
<Button Classes="edit-toolbar-button"
Click="OnRestoreComponentLibraryClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Apps" IconVariant="Regular" />
<TextBlock Text="找回组件库" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Border Classes="edit-toolbar-separator" />
<Button Classes="edit-toolbar-button"
Click="OnExitEditClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Dismiss" IconVariant="Regular" />
<TextBlock Text="退出编辑" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Border>
</Grid>
</Window>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlatformPublishResult(
string Platform,
string DistributionId,
string CurrentAppDirectory,
string? PreviousDirectory,
string PreviousVersion,
string FileMapPath,
string SignaturePath,
string DistributionPath,
string LatestPath,
IReadOnlyList<string> InstallerFiles);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsBuildOptions(
string ReleaseTag,
string AssetsDirectory,
string OutputRoot,
string PrivateKeyPath,
string Repository,
string? S3BaseUrl = null);

View File

@@ -0,0 +1,157 @@
namespace Plonds.Core.Publishing;
public static class PlondsCommitAnalyzer
{
private static readonly string[] SourceDirectories =
[
"LanMountainDesktop/",
"LanMountainDesktop.Launcher/",
"LanMountainDesktop.Shared.Contracts/",
"LanMountainDesktop.PluginSdk/",
"LanMountainDesktop.Appearance/",
"LanMountainDesktop.Settings.Core/",
"LanMountainDesktop.ComponentSystem/"
];
private static readonly (string Prefix, string[] Artifacts)[] SourceToArtifactMappings =
[
("LanMountainDesktop/", ["LanMountainDesktop.dll", "LanMountainDesktop.exe"]),
("LanMountainDesktop.Launcher/", ["LanMountainDesktop.Launcher.exe", "LanMountainDesktop.Launcher.dll"]),
("LanMountainDesktop.Shared.Contracts/", ["LanMountainDesktop.Shared.Contracts.dll"]),
("LanMountainDesktop.PluginSdk/", ["LanMountainDesktop.PluginSdk.dll"]),
("LanMountainDesktop.Appearance/", ["LanMountainDesktop.Appearance.dll"]),
("LanMountainDesktop.Settings.Core/", ["LanMountainDesktop.Settings.Core.dll"]),
("LanMountainDesktop.ComponentSystem/", ["LanMountainDesktop.ComponentSystem.dll"])
];
private static readonly string[] SourceCodeExtensions =
[
".cs", ".axaml", ".xaml", ".csproj"
];
public static HashSet<string> GetChangedSourceFiles(string baselineTag, string currentTag)
{
var changedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var start = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "git",
Arguments = $"log --name-only --pretty=format: {baselineTag}..{currentTag}",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
});
if (start is null)
{
return changedFiles;
}
var output = start.StandardOutput.ReadToEnd();
start.WaitForExit();
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim().Replace('\\', '/');
if (string.IsNullOrWhiteSpace(trimmed))
{
continue;
}
if (!IsSourceDirectoryFile(trimmed))
{
continue;
}
changedFiles.Add(trimmed);
}
return changedFiles;
}
public static HashSet<string> MapSourceFilesToArtifacts(IReadOnlySet<string> sourceFiles)
{
var artifacts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var hasUnmappedChanges = false;
foreach (var sourceFile in sourceFiles)
{
var normalized = sourceFile.Replace('\\', '/');
var mapped = false;
foreach (var (prefix, artifactList) in SourceToArtifactMappings)
{
if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!IsSourceCodeFile(normalized) && !IsConfigFile(normalized))
{
continue;
}
foreach (var artifact in artifactList)
{
artifacts.Add(artifact);
}
mapped = true;
break;
}
if (!mapped && IsConfigFile(normalized))
{
var artifactPath = MapConfigToArtifact(normalized);
if (artifactPath is not null)
{
artifacts.Add(artifactPath);
mapped = true;
}
}
if (!mapped)
{
hasUnmappedChanges = true;
}
}
if (hasUnmappedChanges)
{
foreach (var (_, artifactList) in SourceToArtifactMappings)
{
foreach (var artifact in artifactList)
{
artifacts.Add(artifact);
}
}
}
return artifacts;
}
public static bool IsSourceDirectoryFile(string path)
{
var normalized = path.Replace('\\', '/');
return SourceDirectories.Any(d => normalized.StartsWith(d, StringComparison.OrdinalIgnoreCase));
}
private static bool IsSourceCodeFile(string path)
{
var ext = Path.GetExtension(path);
return SourceCodeExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
}
private static bool IsConfigFile(string path)
{
var ext = Path.GetExtension(path);
return string.Equals(ext, ".json", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase);
}
private static string? MapConfigToArtifact(string sourcePath)
{
var fileName = Path.GetFileName(sourcePath);
return fileName;
}
}

View File

@@ -0,0 +1,14 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsCommitDeltaBuildOptions(
string Platform,
string CurrentVersion,
string CurrentPayloadZip,
string OutputRoot,
string Channel,
string BaselineTag,
string CurrentTag,
string? FallbackBaselineZip = null,
string? BaselineVersion = null,
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
string HashAlgorithm = "sha256");

View File

@@ -0,0 +1,13 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsCommitDeltaBuildResult(
string Platform,
string ChangedZipPath,
string ManifestPath,
bool IsFullUpdate,
bool RequiresCleanInstall,
bool FellBackToFileCompare,
string CurrentVersion,
string? BaselineVersion,
IReadOnlyList<string> ChangedSourceFiles,
IReadOnlyList<string> MappedArtifactFiles);

View File

@@ -0,0 +1,241 @@
using System.Security.Cryptography;
using System.Text.Json;
using Plonds.Shared;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsCommitDeltaBuilder
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public PlondsCommitDeltaBuildResult Build(PlondsCommitDeltaBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var hashAlgorithm = PlondsDeltaBuilder.ValidateHashAlgorithmInternal(options.HashAlgorithm);
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
if (!File.Exists(currentPayloadZip))
{
throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip);
}
var outputRoot = Path.GetFullPath(options.OutputRoot);
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
var currentExtractRoot = Path.Combine(workRoot, "current");
Directory.CreateDirectory(outputRoot);
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var changedSourceFiles = PlondsCommitAnalyzer.GetChangedSourceFiles(options.BaselineTag, options.CurrentTag);
if (changedSourceFiles.Count == 0)
{
return FallbackToFileCompare(options, currentPayloadZip, outputRoot, workRoot, hashAlgorithm);
}
var mappedArtifacts = PlondsCommitAnalyzer.MapSourceFilesToArtifacts(changedSourceFiles);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var artifact in mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
if (!currentManifest.TryGetValue(artifact, out var fingerprint))
{
continue;
}
var hash = GetHash(fingerprint, hashAlgorithm);
var action = PlondsConstants.ActionReplace;
filesMap[artifact] = new PlondsFileEntry(action, hash, fingerprint.Size, hashAlgorithm);
changedFilesMap[artifact] = new PlondsChangedFileEntry(artifact, hash, fingerprint.Size, hashAlgorithm);
}
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, mappedArtifacts, outputRoot, options.Platform);
var requiresCleanInstall = mappedArtifacts.Contains(options.LauncherRelativePath, StringComparer.OrdinalIgnoreCase);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var manifest = new PlondsManifest(
FormatVersion: PlondsConstants.FormatVersion,
CurrentVersion: options.CurrentVersion,
PreviousVersion: options.BaselineVersion ?? options.BaselineTag.TrimStart('v'),
IsFullUpdate: false,
RequiresCleanInstall: requiresCleanInstall,
Channel: options.Channel,
Platform: options.Platform,
UpdatedAt: DateTimeOffset.UtcNow,
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
HashAlgorithm: hashAlgorithm,
FilesMap: filesMap,
ChangedFilesMap: changedFilesMap,
Checksums: new Dictionary<string, string>
{
["changed.zip"] = $"md5:{changedZipMd5}"
});
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(manifestPath, manifestJson);
return new PlondsCommitDeltaBuildResult(
Platform: options.Platform,
ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
IsFullUpdate: false,
RequiresCleanInstall: requiresCleanInstall,
FellBackToFileCompare: false,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineVersion,
ChangedSourceFiles: changedSourceFiles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(),
MappedArtifactFiles: mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray());
}
private PlondsCommitDeltaBuildResult FallbackToFileCompare(
PlondsCommitDeltaBuildOptions options,
string currentPayloadZip,
string outputRoot,
string workRoot,
string hashAlgorithm)
{
var fallbackZip = string.IsNullOrWhiteSpace(options.FallbackBaselineZip)
? null
: Path.GetFullPath(options.FallbackBaselineZip);
if (string.IsNullOrWhiteSpace(fallbackZip) || !File.Exists(fallbackZip))
{
var currentExtractRoot = Path.Combine(workRoot, "current");
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var fp = currentManifest[path];
var hash = GetHash(fp, hashAlgorithm);
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionAdd, hash, fp.Size, hashAlgorithm);
changedFilesMap[path] = new PlondsChangedFileEntry(path, hash, fp.Size, hashAlgorithm);
}
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, filesMap.Keys.ToHashSet(), outputRoot, options.Platform);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var manifest = new PlondsManifest(
FormatVersion: PlondsConstants.FormatVersion,
CurrentVersion: options.CurrentVersion,
PreviousVersion: "0.0.0",
IsFullUpdate: true,
RequiresCleanInstall: false,
Channel: options.Channel,
Platform: options.Platform,
UpdatedAt: DateTimeOffset.UtcNow,
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
HashAlgorithm: hashAlgorithm,
FilesMap: filesMap,
ChangedFilesMap: changedFilesMap,
Checksums: new Dictionary<string, string>
{
["changed.zip"] = $"md5:{changedZipMd5}"
});
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(manifestPath, manifestJson);
return new PlondsCommitDeltaBuildResult(
Platform: options.Platform,
ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
IsFullUpdate: true,
RequiresCleanInstall: false,
FellBackToFileCompare: true,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineVersion,
ChangedSourceFiles: [],
MappedArtifactFiles: []);
}
var deltaBuilder = new PlondsDeltaBuilder();
var deltaResult = deltaBuilder.Build(new PlondsDeltaBuildOptions(
Platform: options.Platform,
CurrentVersion: options.CurrentVersion,
CurrentPayloadZip: currentPayloadZip,
OutputRoot: outputRoot,
Channel: options.Channel,
BaselineVersion: options.BaselineVersion,
BaselinePayloadZip: fallbackZip,
LauncherRelativePath: options.LauncherRelativePath,
HashAlgorithm: hashAlgorithm));
return new PlondsCommitDeltaBuildResult(
Platform: deltaResult.Platform,
ChangedZipPath: deltaResult.ChangedZipPath,
ManifestPath: deltaResult.ManifestPath,
IsFullUpdate: deltaResult.IsFullUpdate,
RequiresCleanInstall: deltaResult.RequiresCleanInstall,
FellBackToFileCompare: true,
CurrentVersion: deltaResult.CurrentVersion,
BaselineVersion: deltaResult.BaselineVersion,
ChangedSourceFiles: [],
MappedArtifactFiles: []);
}
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
{
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
{
return ComputeMd5Hex(fingerprint.FullPath);
}
return fingerprint.Sha256;
}
private static string CreateChangedZipFromArtifacts(
string currentExtractRoot,
IReadOnlySet<string> artifacts,
string outputRoot,
string platform)
{
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
foreach (var artifact in artifacts)
{
var sourcePath = Path.Combine(currentExtractRoot, artifact);
if (!File.Exists(sourcePath))
{
continue;
}
var destPath = Path.Combine(stagingRoot, artifact);
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrWhiteSpace(destDir))
{
Directory.CreateDirectory(destDir);
}
File.Copy(sourcePath, destPath, overwrite: true);
}
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
return changedZipPath;
}
private static string ComputeMd5Hex(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
}
}

View File

@@ -1,16 +1,12 @@
namespace Plonds.Core.Publishing;
namespace Plonds.Core.Publishing;
public sealed record PlondsDeltaBuildOptions(
string Platform,
string CurrentVersion,
string CurrentTag,
string CurrentPayloadZip,
string OutputRoot,
string PrivateKeyPath,
string Channel = "stable",
string? BaselineVersion = null,
string? BaselineTag = null,
string? BaselinePayloadZip = null,
bool IsFullPayload = false,
string? StaticOutputRoot = null,
string? UpdateBaseUrl = null);
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
string HashAlgorithm = "sha256");

View File

@@ -1,13 +1,10 @@
namespace Plonds.Core.Publishing;
namespace Plonds.Core.Publishing;
public sealed record PlondsDeltaBuildResult(
string Platform,
string DistributionId,
string UpdateArchivePath,
string FileMapPath,
string FileMapSignaturePath,
string SummaryPath,
bool IsFullPayload,
string? BaselineTag,
string? BaselineVersion,
string TargetVersion);
string ChangedZipPath,
string ManifestPath,
bool IsFullUpdate,
bool RequiresCleanInstall,
string CurrentVersion,
string? BaselineVersion);

View File

@@ -1,16 +1,24 @@
using Plonds.Core.Security;
using System.Security.Cryptography;
using System.Text.Json;
using Plonds.Shared;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsDeltaBuilder
{
private readonly RsaFileSigner _signer = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var hashAlgorithm = ValidateHashAlgorithmInternal(options.HashAlgorithm);
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
if (!File.Exists(currentPayloadZip))
{
@@ -29,332 +37,200 @@ public sealed class PlondsDeltaBuilder
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
var currentExtractRoot = Path.Combine(workRoot, "current");
var baselineExtractRoot = Path.Combine(workRoot, "baseline");
var objectsRoot = Path.Combine(workRoot, "objects");
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
var summaryRoot = Path.Combine(outputRoot, "platform-summaries");
Directory.CreateDirectory(releaseAssetsRoot);
Directory.CreateDirectory(summaryRoot);
Directory.CreateDirectory(outputRoot);
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip);
if (useFullPayload)
{
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
}
else
var isFullUpdate = string.IsNullOrWhiteSpace(baselinePayloadZip);
if (!isFullUpdate)
{
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
}
PayloadUtilities.EnsureCleanDirectory(objectsRoot);
var previousManifest = useFullPayload
var previousManifest = isFullUpdate
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl)
? null
: options.UpdateBaseUrl.TrimEnd('/');
var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl)
? null
: $"{updateBaseUrl}/repo/sha256";
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl);
var updateAssetName = $"update-{options.Platform}.zip";
var fileMapAssetName = $"plonds-filemap-{options.Platform}.json";
var fileMapSignatureAssetName = fileMapAssetName + ".sig";
var distributionId = $"plonds-{options.CurrentVersion}-{options.Platform}";
var updateArchivePath = Path.Combine(releaseAssetsRoot, updateAssetName);
var fileMapPath = Path.Combine(releaseAssetsRoot, fileMapAssetName);
var fileMapSignaturePath = Path.Combine(releaseAssetsRoot, fileMapSignatureAssetName);
var filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm);
var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm);
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath);
var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["protocol"] = "PLONDS",
["channel"] = options.Channel,
["releaseTag"] = options.CurrentTag,
["baselineTag"] = options.BaselineTag ?? string.Empty,
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
["targetVersion"] = options.CurrentVersion,
["isFullPayload"] = useFullPayload ? "true" : "false"
};
var launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath);
var requiresCleanInstall = launcherChanged && !isFullUpdate;
var generatedAt = DateTimeOffset.UtcNow;
var component = new ComponentDocument(
Name: "app",
Version: options.CurrentVersion,
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["component"] = "app",
["mode"] = "file-object"
},
Files: fileEntries);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var fileMap = new FileMapDocument(
FormatVersion: "1.0",
DistributionId: distributionId,
FromVersion: options.BaselineVersion ?? "0.0.0",
ToVersion: options.CurrentVersion,
Version: options.CurrentVersion,
Platform: options.Platform,
Arch: PayloadUtilities.ResolveArch(options.Platform),
Channel: options.Channel,
GeneratedAt: generatedAt,
Metadata: metadata,
Components: [component],
Files: fileEntries);
PayloadUtilities.WriteJson(fileMapPath, fileMap);
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl))
{
WriteStaticLayout(
options,
component,
objectsRoot,
distributionId,
fileMapPath,
fileMapSignaturePath,
Path.GetFullPath(options.StaticOutputRoot),
updateBaseUrl,
generatedAt);
}
var summary = new PlondsReleasePlatformEntry(
Platform: options.Platform,
DistributionId: distributionId,
BaselineTag: options.BaselineTag,
BaselineVersion: options.BaselineVersion ?? "0.0.0",
TargetVersion: options.CurrentVersion,
IsFullPayload: useFullPayload,
FilesZipAsset: $"files-{options.Platform}.zip",
UpdateZipAsset: updateAssetName,
FileMapAsset: fileMapAssetName,
FileMapSignatureAsset: fileMapSignatureAssetName,
Sha256: PayloadUtilities.ComputeSha256(updateArchivePath));
var summaryPath = Path.Combine(summaryRoot, $"platform-summary-{options.Platform}.json");
PayloadUtilities.WriteJson(summaryPath, summary);
return new PlondsDeltaBuildResult(
options.Platform,
distributionId,
updateArchivePath,
fileMapPath,
fileMapSignaturePath,
summaryPath,
useFullPayload,
options.BaselineTag,
options.BaselineVersion,
options.CurrentVersion);
}
private static List<FileEntryDocument> BuildFileEntries(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string objectsRoot,
string? repoBaseUrl)
{
var result = new List<FileEntryDocument>();
foreach (var path in currentManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
if (previousManifest.TryGetValue(path, out var previous) &&
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
{
result.Add(new FileEntryDocument(
Path: path,
Action: "reuse",
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: null,
ObjectKey: null,
ObjectUrl: null,
Metadata: null));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
? null
: $"{repoBaseUrl.TrimEnd('/')}/{objectPath}";
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["mode"] = "file-object"
};
if (!string.IsNullOrWhiteSpace(current.UnixFileMode))
{
metadata["unixFileMode"] = current.UnixFileMode!;
}
result.Add(new FileEntryDocument(
Path: path,
Action: action,
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: objectPath,
ObjectKey: objectPath,
ObjectUrl: objectUrl,
Metadata: metadata));
}
foreach (var path in previousManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
{
if (currentManifest.ContainsKey(path))
{
continue;
}
result.Add(new FileEntryDocument(
Path: path,
Action: "delete",
Sha256: string.Empty,
Size: 0,
ObjectPath: null,
ObjectKey: null,
ObjectUrl: null,
Metadata: null));
}
return result;
}
private static void WriteStaticLayout(
PlondsDeltaBuildOptions options,
ComponentDocument component,
string objectsRoot,
string distributionId,
string fileMapPath,
string fileMapSignaturePath,
string staticOutputRoot,
string updateBaseUrl,
DateTimeOffset generatedAt)
{
var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256");
var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId);
var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions");
var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform);
CopyDirectory(objectsRoot, repoRoot);
Directory.CreateDirectory(manifestRoot);
File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true);
File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true);
var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json";
var distribution = new DistributionDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
SourceVersion: options.BaselineVersion ?? "0.0.0",
var manifest = new PlondsManifest(
FormatVersion: PlondsConstants.FormatVersion,
CurrentVersion: options.CurrentVersion,
PreviousVersion: options.BaselineVersion ?? "0.0.0",
IsFullUpdate: isFullUpdate,
RequiresCleanInstall: requiresCleanInstall,
Channel: options.Channel,
Platform: options.Platform,
Arch: PayloadUtilities.ResolveArch(options.Platform),
PublishedAt: generatedAt,
FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapUrl + ".sig",
Components: [component],
InstallerMirrors: [],
Capabilities: ["file-object"],
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
UpdatedAt: DateTimeOffset.UtcNow,
CompareMethod: PlondsConstants.CompareMethodFileCompare,
HashAlgorithm: hashAlgorithm,
FilesMap: filesMap,
ChangedFilesMap: changedFilesMap,
Checksums: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["releaseTag"] = options.CurrentTag,
["baselineTag"] = options.BaselineTag ?? string.Empty,
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
["targetVersion"] = options.CurrentVersion,
["isFullPayload"] = options.IsFullPayload ? "true" : "false"
["changed.zip"] = $"md5:{changedZipMd5}"
});
var latest = new LatestPointerDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
Channel: options.Channel,
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(manifestPath, manifestJson);
return new PlondsDeltaBuildResult(
Platform: options.Platform,
PublishedAt: generatedAt);
PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution);
PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest);
ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
IsFullUpdate: isFullUpdate,
RequiresCleanInstall: requiresCleanInstall,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineVersion);
}
private static void CopyDirectory(string sourceDir, string destinationDir)
internal static string ValidateHashAlgorithmInternal(string algorithm)
{
Directory.CreateDirectory(destinationDir);
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
var normalized = algorithm.Trim().ToLowerInvariant();
if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5))
{
var relativePath = Path.GetRelativePath(sourceDir, directory);
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5");
}
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, file);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(file, destinationPath, overwrite: true);
}
return normalized;
}
private sealed record FileMapDocument(
string FormatVersion,
string DistributionId,
string FromVersion,
string ToVersion,
string Version,
string Platform,
string Arch,
string Channel,
DateTimeOffset GeneratedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<FileEntryDocument> Files);
private static Dictionary<string, PlondsFileEntry> BuildFilesMap(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string hashAlgorithm)
{
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
private sealed record ComponentDocument(
string Name,
string Version,
IReadOnlyDictionary<string, string>? Metadata,
IReadOnlyList<FileEntryDocument> Files);
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
var currentHash = GetHash(current, hashAlgorithm);
private sealed record FileEntryDocument(
string Path,
string Action,
string Sha256,
long Size,
string? ObjectPath,
string? ObjectKey,
string? ObjectUrl,
IReadOnlyDictionary<string, string>? Metadata);
if (previousManifest.TryGetValue(path, out var previous))
{
var previousHash = GetHash(previous, hashAlgorithm);
if (string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase))
{
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReuse, currentHash, current.Size, hashAlgorithm);
continue;
}
}
private sealed record DistributionDocument(
string DistributionId,
string Version,
string SourceVersion,
string Channel,
string Platform,
string Arch,
DateTimeOffset PublishedAt,
string FileMapUrl,
string FileMapSignatureUrl,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string>? Metadata);
var action = previousManifest.ContainsKey(path)
? PlondsConstants.ActionReplace
: PlondsConstants.ActionAdd;
filesMap[path] = new PlondsFileEntry(action, currentHash, current.Size, hashAlgorithm);
}
private sealed record LatestPointerDocument(
string DistributionId,
string Version,
string Channel,
string Platform,
DateTimeOffset PublishedAt);
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
if (!currentManifest.ContainsKey(path))
{
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionDelete, string.Empty, 0, hashAlgorithm);
}
}
private sealed record InstallerMirrorDocument(
string Platform,
string? Url,
string? FileName,
string? Sha256,
long Size);
return filesMap;
}
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
{
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
{
return ComputeMd5Hex(fingerprint.FullPath);
}
return fingerprint.Sha256;
}
private static Dictionary<string, PlondsChangedFileEntry> BuildChangedFilesMap(
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string hashAlgorithm)
{
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var (path, entry) in filesMap)
{
if (entry.Action is PlondsConstants.ActionAdd or PlondsConstants.ActionReplace)
{
changedFilesMap[path] = new PlondsChangedFileEntry(path, entry.Hash, entry.Size, hashAlgorithm);
}
}
return changedFilesMap;
}
private static string CreateChangedZip(
string currentExtractRoot,
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string outputRoot,
string platform)
{
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
foreach (var (path, entry) in filesMap)
{
if (entry.Action is not (PlondsConstants.ActionAdd or PlondsConstants.ActionReplace))
{
continue;
}
var sourcePath = Path.Combine(currentExtractRoot, path);
if (!File.Exists(sourcePath))
{
continue;
}
var destPath = Path.Combine(stagingRoot, path);
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrWhiteSpace(destDir))
{
Directory.CreateDirectory(destDir);
}
File.Copy(sourcePath, destPath, overwrite: true);
}
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
return changedZipPath;
}
private static bool DetectLauncherChange(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string launcherRelativePath)
{
var normalizedPath = launcherRelativePath.Replace('\\', '/');
if (!currentManifest.TryGetValue(normalizedPath, out var current))
{
return false;
}
if (!previousManifest.TryGetValue(normalizedPath, out var previous))
{
return true;
}
return !string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase);
}
private static string ComputeMd5Hex(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
}
}

View File

@@ -1,23 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsGenerateOptions(
string CurrentVersion,
string CurrentDirectory,
string Platform,
string OutputRoot,
string PreviousVersion = "0.0.0",
string? PreviousDirectory = null,
string Channel = "stable",
string? DistributionId = null,
string? RepoBaseUrl = null,
string? FileMapUrl = null,
string? FileMapSignatureUrl = null,
string? InstallerDirectory = null,
string? InstallerBaseUrl = null,
string IncrementalStrategy = "release-payload",
string? BaselineVersion = null,
string? BaselineRef = null,
string? SourceCommit = null,
bool IsFullPayloadRelease = false,
string? CommitRangeStart = null,
string? CommitRangeEnd = null);

View File

@@ -1,451 +0,0 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Plonds.Core.Publishing;
public sealed class PlondsGenerator
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public PlatformPublishResult Generate(PlondsGenerateOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var currentDirectory = Path.GetFullPath(options.CurrentDirectory);
if (!Directory.Exists(currentDirectory))
{
throw new DirectoryNotFoundException($"Current directory not found: {currentDirectory}");
}
var previousDirectory = string.IsNullOrWhiteSpace(options.PreviousDirectory)
? null
: Path.GetFullPath(options.PreviousDirectory);
var distributionId = string.IsNullOrWhiteSpace(options.DistributionId)
? $"plonds-{options.CurrentVersion}-{options.Platform}"
: options.DistributionId.Trim();
var outputRoot = Path.GetFullPath(options.OutputRoot);
var repoRoot = Path.Combine(outputRoot, "repo", "sha256");
var manifestsRoot = Path.Combine(outputRoot, "manifests", distributionId);
var metaDistributionRoot = Path.Combine(outputRoot, "meta", "distributions");
var metaChannelRoot = Path.Combine(outputRoot, "meta", "channels", options.Channel, options.Platform);
var installerMirrorRoot = Path.Combine(outputRoot, "installers", options.Platform, options.CurrentVersion);
Directory.CreateDirectory(repoRoot);
Directory.CreateDirectory(manifestsRoot);
Directory.CreateDirectory(metaDistributionRoot);
Directory.CreateDirectory(metaChannelRoot);
var previousManifest = options.IsFullPayloadRelease
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: ScanDirectory(previousDirectory);
var currentManifest = ScanDirectory(currentDirectory);
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
var publishedAt = DateTimeOffset.UtcNow;
var generatedAt = DateTimeOffset.UtcNow;
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var arch = ResolveArch(options.Platform);
var fileMap = new FileMapDocument(
FormatVersion: "2.0",
DistributionId: distributionId,
FromVersion: options.PreviousVersion,
ToVersion: options.CurrentVersion,
Version: options.CurrentVersion,
Platform: options.Platform,
Arch: arch,
Channel: options.Channel,
PublishedAt: publishedAt,
GeneratedAt: generatedAt,
BaselineVersion: baselineVersion,
Capabilities: ["file-object", "compressed-object"],
Components:
[
new ComponentDocument(
Id: "app",
Root: "/",
Mode: "file-object",
Files: fileEntries,
Metadata: new Dictionary<string, string> { ["component"] = "app" })
],
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["mode"] = "file-object",
["baselineVersion"] = baselineVersion,
["incrementalStrategy"] = options.IncrementalStrategy,
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
["sourceCommit"] = options.SourceCommit ?? string.Empty,
["baselineRef"] = options.BaselineRef ?? string.Empty,
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
});
var distribution = new DistributionDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
Channel: options.Channel,
Platform: options.Platform,
Arch: arch,
PublishedAt: publishedAt,
FileMapUrl: options.FileMapUrl,
FileMapSignatureUrl: options.FileMapSignatureUrl,
Components: fileMap.Components,
InstallerMirrors: installerMirrors,
Capabilities: ["file-object", "compressed-object"],
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["baselineVersion"] = baselineVersion,
["incrementalStrategy"] = options.IncrementalStrategy,
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
["sourceCommit"] = options.SourceCommit ?? string.Empty,
["baselineRef"] = options.BaselineRef ?? string.Empty,
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
});
var latest = new LatestPointerDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
Channel: options.Channel,
Platform: options.Platform,
PublishedAt: publishedAt);
var fileMapPath = Path.Combine(manifestsRoot, "plonds-filemap.json");
var distributionPath = Path.Combine(metaDistributionRoot, distributionId + ".json");
var latestPath = Path.Combine(metaChannelRoot, "latest.json");
WriteJson(fileMapPath, fileMap);
WriteJson(distributionPath, distribution);
WriteJson(latestPath, latest);
return new PlatformPublishResult(
options.Platform,
distributionId,
currentDirectory,
previousDirectory,
options.PreviousVersion,
fileMapPath,
fileMapPath + ".sig",
distributionPath,
latestPath,
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
}
public static void WriteBundle(string fileMapPath, string signatureBase64)
{
var fileMapJson = File.ReadAllText(fileMapPath);
WriteBundle(fileMapPath, fileMapJson, signatureBase64);
}
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
{
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
return manifest;
}
var resolvedRoot = Path.GetFullPath(root);
foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(resolvedRoot, filePath).Replace('\\', '/');
if (ShouldIgnore(relativePath))
{
continue;
}
var fileInfo = new FileInfo(filePath);
manifest[relativePath] = new FileFingerprint(relativePath, filePath, ComputeSha256(filePath), fileInfo.Length);
}
return manifest;
}
private static List<FileEntryDocument> BuildFileEntries(
Dictionary<string, FileFingerprint> previousManifest,
Dictionary<string, FileFingerprint> currentManifest,
string repoRoot,
string? repoBaseUrl)
{
var entries = new List<FileEntryDocument>();
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
if (previousManifest.TryGetValue(path, out var previous) &&
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
{
entries.Add(new FileEntryDocument(
Path: path,
Action: "reuse",
Sha256: current.Sha256,
Size: current.Size,
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
ArchiveSha256: null,
Metadata: new Dictionary<string, string> { ["reuseVerified"] = "true" }));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var (objectKey, archiveSha256, mode) = CopyContentObjectWithCompression(
current.FullPath, repoRoot, current.Sha256, current.Size);
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
? null
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
entries.Add(new FileEntryDocument(
Path: path,
Action: action,
Sha256: current.Sha256,
Size: current.Size,
Mode: mode,
ObjectKey: objectKey,
ObjectUrl: objectUrl,
ArchiveSha256: string.IsNullOrEmpty(archiveSha256) ? null : archiveSha256,
Metadata: new Dictionary<string, string> { ["mode"] = mode }));
}
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
if (!currentManifest.ContainsKey(path))
{
entries.Add(new FileEntryDocument(
Path: path,
Action: "delete",
Sha256: string.Empty,
Size: 0,
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
ArchiveSha256: null,
Metadata: null));
}
}
return entries;
}
private static List<InstallerMirrorDocument> BuildInstallerMirrors(
string platform,
string installerMirrorRoot,
string? installerSourceDirectory,
string? installerBaseUrl)
{
var result = new List<InstallerMirrorDocument>();
if (string.IsNullOrWhiteSpace(installerSourceDirectory) || !Directory.Exists(installerSourceDirectory))
{
return result;
}
Directory.CreateDirectory(installerMirrorRoot);
foreach (var sourceFile in Directory.EnumerateFiles(installerSourceDirectory))
{
var fileName = Path.GetFileName(sourceFile);
var destinationPath = Path.Combine(installerMirrorRoot, fileName);
File.Copy(sourceFile, destinationPath, overwrite: true);
var url = string.IsNullOrWhiteSpace(installerBaseUrl)
? null
: $"{installerBaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}";
result.Add(new InstallerMirrorDocument(
Platform: platform,
Arch: ResolveArch(platform),
Url: url,
Name: fileName,
FileName: fileName,
Sha256: ComputeSha256(destinationPath),
Size: new FileInfo(destinationPath).Length));
}
return result;
}
private static string ResolveArch(string platform)
{
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
{
return "x86";
}
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
{
return "arm64";
}
return "x64";
}
private static bool ShouldIgnore(string relativePath)
{
var normalized = relativePath.Trim().Replace('\\', '/');
if (string.IsNullOrWhiteSpace(normalized))
{
return true;
}
return normalized.Equals(".current", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals(".partial", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals(".destroy", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(".current/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(".partial/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(".destroy/", StringComparison.OrdinalIgnoreCase);
}
private static string CopyContentObject(string sourcePath, string repoRoot, string sha256)
{
var prefix = sha256[..Math.Min(2, sha256.Length)];
var relativeKey = $"{prefix}/{sha256}";
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.Copy(sourcePath, destinationPath, overwrite: true);
}
return relativeKey.Replace('\\', '/');
}
private static (string ObjectKey, string ArchiveSha256, string Mode) CopyContentObjectWithCompression(
string sourcePath, string repoRoot, string sha256, long fileSize)
{
if (fileSize > 65536)
{
var compressedBytes = CompressGzip(sourcePath);
var archiveSha256 = ComputeSha256FromBytes(compressedBytes);
var archiveKey = CopyBytesToObjectStore(compressedBytes, repoRoot, archiveSha256);
return (archiveKey, archiveSha256, "compressed-object");
}
var key = CopyContentObject(sourcePath, repoRoot, sha256);
return (key, string.Empty, "file-object");
}
private static byte[] CompressGzip(string filePath)
{
using var input = File.OpenRead(filePath);
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true))
{
input.CopyTo(gzip);
}
return output.ToArray();
}
private static string ComputeSha256FromBytes(byte[] data)
{
return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}
private static string CopyBytesToObjectStore(byte[] data, string repoRoot, string sha256)
{
var prefix = sha256[..Math.Min(2, sha256.Length)];
var relativeKey = $"{prefix}/{sha256}";
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.WriteAllBytes(destinationPath, data);
}
return relativeKey.Replace('\\', '/');
}
private static void WriteBundle(string fileMapPath, string fileMapJson, string signatureBase64)
{
var bundle = new BundleDocument(fileMapJson, signatureBase64);
WriteJson(fileMapPath + ".bundle.json", bundle);
}
private static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static void WriteJson<T>(string path, T value)
{
var json = JsonSerializer.Serialize(value, JsonOptions);
File.WriteAllText(path, json, new UTF8Encoding(false));
}
private sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size);
private sealed record FileMapDocument(
string FormatVersion,
string DistributionId,
string FromVersion,
string ToVersion,
string Version,
string Platform,
string Arch,
string Channel,
DateTimeOffset PublishedAt,
DateTimeOffset GeneratedAt,
string? BaselineVersion,
IReadOnlyList<string> Capabilities,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record DistributionDocument(
string DistributionId,
string Version,
string Channel,
string Platform,
string Arch,
DateTimeOffset PublishedAt,
string? FileMapUrl,
string? FileMapSignatureUrl,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record LatestPointerDocument(
string DistributionId,
string Version,
string Channel,
string Platform,
DateTimeOffset PublishedAt);
private sealed record ComponentDocument(
string Id,
string Root,
string Mode,
IReadOnlyList<FileEntryDocument> Files,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record FileEntryDocument(
string Path,
string Action,
string Sha256,
long Size,
string Mode,
string? ObjectKey,
string? ObjectUrl,
string? ArchiveSha256,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record InstallerMirrorDocument(
string Platform,
string Arch,
string? Url,
string? Name,
string? FileName,
string? Sha256,
long Size);
private sealed record BundleDocument(string Manifest, string Signature);
}

View File

@@ -1,68 +0,0 @@
using Plonds.Core.Security;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsManifestBuilder
{
private readonly RsaFileSigner _signer = new();
public string Build(PlondsBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var assetsDirectory = Path.GetFullPath(options.AssetsDirectory);
if (!Directory.Exists(assetsDirectory))
{
throw new DirectoryNotFoundException($"PLONDS assets directory not found: {assetsDirectory}");
}
var assetEntries = Directory
.EnumerateFiles(assetsDirectory, "*", SearchOption.TopDirectoryOnly)
.Where(static path =>
{
var name = Path.GetFileName(path);
return !name.Equals("plonds.json", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("plonds.json.sig", StringComparison.OrdinalIgnoreCase);
})
.OrderBy(static path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
.Select(path => BuildAssetEntry(path, options.Repository, options.ReleaseTag, options.S3BaseUrl))
.ToArray();
var manifest = new PlondsManifest(
FormatVersion: "1.0",
ReleaseTag: options.ReleaseTag,
GeneratedAt: DateTimeOffset.UtcNow,
Assets: assetEntries);
var outputRoot = Path.GetFullPath(options.OutputRoot);
Directory.CreateDirectory(outputRoot);
var manifestPath = Path.Combine(outputRoot, "plonds.json");
PayloadUtilities.WriteJson(manifestPath, manifest);
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
return manifestPath;
}
private static PlondsAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
{
var fileName = Path.GetFileName(assetPath);
var mirrors = new List<PlondsMirrorEntry>
{
new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}")
};
if (!string.IsNullOrWhiteSpace(s3BaseUrl))
{
mirrors.Add(new PlondsMirrorEntry(
"s3",
$"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}"));
}
return new PlondsAssetEntry(
AssetId: fileName,
FileName: fileName,
Sha256: PayloadUtilities.ComputeSha256(assetPath),
Size: new FileInfo(assetPath).Length,
Mirrors: mirrors);
}
}

View File

@@ -1,19 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsPublishOptions(
string Version,
string AppArtifactsRoot,
string InstallerArtifactsRoot,
string OutputRoot,
string PrivateKeyPath,
string Channel = "stable",
string? BaselineRoot = null,
string? RepoBaseUrl = null,
string? InstallerBaseUrl = null,
string IncrementalStrategy = "release-payload",
string? BaselineVersion = null,
string? BaselineRef = null,
string? SourceCommit = null,
bool IsFullPayloadRelease = false,
string? CommitRangeStart = null,
string? CommitRangeEnd = null);

View File

@@ -1,237 +0,0 @@
using System.Text;
using System.Text.Json;
using Plonds.Core.Security;
using Plonds.Shared;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsPublisher
{
private static readonly PlatformConfig[] SupportedPlatforms =
[
new("windows-x64", "app-payload-windows-x64", [".exe"], ["x64"]),
new("windows-x86", "app-payload-windows-x86", [".exe"], ["x86"]),
new("linux-x64", "app-payload-linux-x64", [".deb"], ["linux", "x64"])
];
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private readonly PlondsGenerator _generator = new();
private readonly RsaFileSigner _signer = new();
public IReadOnlyList<PlatformPublishResult> Publish(PlondsPublishOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var results = new List<PlatformPublishResult>();
var releaseAssetsRoot = Path.Combine(Path.GetFullPath(options.OutputRoot), "release-assets");
Directory.CreateDirectory(releaseAssetsRoot);
foreach (var config in SupportedPlatforms)
{
var artifactRoot = Path.Combine(Path.GetFullPath(options.AppArtifactsRoot), config.ArtifactName);
if (!Directory.Exists(artifactRoot))
{
throw new DirectoryNotFoundException($"App payload artifact root not found for {config.Platform}: {artifactRoot}");
}
var currentAppDirectory = FindCurrentAppDirectory(artifactRoot, options.Version);
if (currentAppDirectory is null)
{
throw new DirectoryNotFoundException($"Unable to locate app payload directory for {config.Platform} under {artifactRoot}");
}
var baselineRoot = string.IsNullOrWhiteSpace(options.BaselineRoot)
? Path.Combine(Path.GetFullPath(options.OutputRoot), "_baselines")
: Path.GetFullPath(options.BaselineRoot);
var platformBaselineRoot = Path.Combine(baselineRoot, config.Platform);
var previousDirectory = Path.Combine(platformBaselineRoot, "current");
var previousVersionPath = Path.Combine(platformBaselineRoot, "version.txt");
Directory.CreateDirectory(platformBaselineRoot);
if (!Directory.Exists(previousDirectory))
{
Directory.CreateDirectory(previousDirectory);
}
var previousVersion = File.Exists(previousVersionPath)
? File.ReadAllText(previousVersionPath).Trim()
: "0.0.0";
var installerSourceDirectory = PrepareInstallerMirrorInput(
config,
options.InstallerArtifactsRoot,
Path.Combine(platformBaselineRoot, "installers"));
var distributionId = $"plonds-{options.Version}-{config.Platform}";
var repoBaseUrl = options.RepoBaseUrl;
var fileMapUrl = repoBaseUrl is null
? null
: $"{repoBaseUrl.TrimEnd('/').Replace("/repo/sha256", "/manifests")}/{distributionId}/plonds-filemap.json";
var fileMapSignatureUrl = fileMapUrl is null ? null : fileMapUrl + ".sig";
var installerBaseUrl = string.IsNullOrWhiteSpace(options.InstallerBaseUrl)
? null
: $"{options.InstallerBaseUrl.TrimEnd('/')}/{config.Platform}/{options.Version}";
var result = _generator.Generate(new PlondsGenerateOptions(
CurrentVersion: options.Version,
CurrentDirectory: currentAppDirectory,
Platform: config.Platform,
OutputRoot: options.OutputRoot,
PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
PreviousDirectory: previousDirectory,
Channel: options.Channel,
DistributionId: distributionId,
RepoBaseUrl: repoBaseUrl,
FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapSignatureUrl,
InstallerDirectory: installerSourceDirectory,
InstallerBaseUrl: installerBaseUrl,
IncrementalStrategy: options.IncrementalStrategy,
BaselineVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
BaselineRef: options.BaselineRef,
SourceCommit: options.SourceCommit,
IsFullPayloadRelease: options.IsFullPayloadRelease,
CommitRangeStart: options.CommitRangeStart,
CommitRangeEnd: options.CommitRangeEnd));
_signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);
CopyReleaseAsset(result.FileMapPath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json"));
CopyReleaseAsset(result.SignaturePath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json.sig"));
CopyReleaseAsset(result.DistributionPath, Path.Combine(releaseAssetsRoot, $"plonds-distribution-{config.Platform}.json"));
CopyReleaseAsset(result.LatestPath, Path.Combine(releaseAssetsRoot, $"plonds-latest-{config.Platform}.json"));
MirrorBaseline(currentAppDirectory, previousDirectory, previousVersionPath, options.Version);
results.Add(result);
}
WriteMetadataCatalog(options, results);
return results;
}
private static void WriteMetadataCatalog(PlondsPublishOptions options, IReadOnlyList<PlatformPublishResult> results)
{
var outputRoot = Path.GetFullPath(options.OutputRoot);
var metadataRoot = Path.Combine(outputRoot, "meta");
Directory.CreateDirectory(metadataRoot);
var generatedAt = DateTimeOffset.UtcNow;
var latestPointers = results
.Select(result => new PlondsChannelPointer(
Channel: options.Channel,
Platform: result.Platform,
DistributionId: result.DistributionId,
Version: options.Version,
PublishedAt: generatedAt,
DistributionPath: $"distributions/{result.DistributionId}.json",
FileMapPath: $"../manifests/{result.DistributionId}/plonds-filemap.json"))
.OrderBy(pointer => pointer.Channel, StringComparer.OrdinalIgnoreCase)
.ThenBy(pointer => pointer.Platform, StringComparer.OrdinalIgnoreCase)
.ToArray();
var catalog = new PlondsMetadataCatalog(
ProtocolName: PlondsConstants.ProtocolName,
ProtocolVersion: PlondsConstants.ProtocolVersion,
StorageRoot: outputRoot,
MetaRoot: metadataRoot,
Latest: latestPointers,
Metadata: new Dictionary<string, string>
{
["generatedBy"] = "Plonds.Tool",
["channel"] = options.Channel,
["generatedAt"] = generatedAt.ToString("O")
});
var metadataPath = Path.Combine(metadataRoot, "metadata.json");
File.WriteAllText(metadataPath, JsonSerializer.Serialize(catalog, JsonOptions), new UTF8Encoding(false));
}
private static void MirrorBaseline(string currentAppDirectory, string previousDirectory, string previousVersionPath, string version)
{
if (Directory.Exists(previousDirectory))
{
Directory.Delete(previousDirectory, recursive: true);
}
CopyDirectory(currentAppDirectory, previousDirectory);
File.WriteAllText(previousVersionPath, version);
}
private static string? FindCurrentAppDirectory(string artifactRoot, string version)
{
var preferred = Directory.EnumerateDirectories(artifactRoot, $"app-{version}", SearchOption.AllDirectories).FirstOrDefault();
if (preferred is not null)
{
return preferred;
}
return Directory.EnumerateDirectories(artifactRoot, "app-*", SearchOption.AllDirectories)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
}
private static string PrepareInstallerMirrorInput(PlatformConfig config, string installerArtifactsRoot, string destinationRoot)
{
var installerFiles = FindInstallerFiles(config, installerArtifactsRoot);
if (Directory.Exists(destinationRoot))
{
Directory.Delete(destinationRoot, recursive: true);
}
Directory.CreateDirectory(destinationRoot);
foreach (var file in installerFiles)
{
File.Copy(file, Path.Combine(destinationRoot, Path.GetFileName(file)), overwrite: true);
}
return destinationRoot;
}
private static List<string> FindInstallerFiles(PlatformConfig config, string installerArtifactsRoot)
{
var files = Directory.EnumerateFiles(Path.GetFullPath(installerArtifactsRoot), "*", SearchOption.AllDirectories);
return files
.Where(file => config.InstallerExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
.Where(file =>
{
var fileName = Path.GetFileName(file);
return config.FileNameTokens.All(token => fileName.Contains(token, StringComparison.OrdinalIgnoreCase));
})
.ToList();
}
private static void CopyReleaseAsset(string sourcePath, string destinationPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(sourcePath, destinationPath, overwrite: true);
}
private static void CopyDirectory(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, directory);
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
}
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, file);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(file, destinationPath, overwrite: true);
}
}
private sealed record PlatformConfig(
string Platform,
string ArtifactName,
IReadOnlyList<string> InstallerExtensions,
IReadOnlyList<string> FileNameTokens);
}

View File

@@ -1,57 +0,0 @@
using System.Text.Json;
using Plonds.Core.Security;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsReleaseIndexBuilder
{
private readonly RsaFileSigner _signer = new();
public string Build(PlondsReleaseIndexOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var summariesDirectory = Path.GetFullPath(options.PlatformSummariesDirectory);
if (!Directory.Exists(summariesDirectory))
{
throw new DirectoryNotFoundException($"Platform summary directory not found: {summariesDirectory}");
}
var summaries = Directory
.EnumerateFiles(summariesDirectory, "platform-summary-*.json", SearchOption.TopDirectoryOnly)
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(ReadSummary)
.OrderBy(static entry => entry.Platform, StringComparer.OrdinalIgnoreCase)
.ToArray();
var manifest = new PlondsReleaseManifest(
FormatVersion: "1.0",
ReleaseTag: options.ReleaseTag,
Version: options.Version,
Channel: options.Channel,
GeneratedAt: DateTimeOffset.UtcNow,
Platforms: summaries);
var outputRoot = Path.GetFullPath(options.OutputRoot);
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
Directory.CreateDirectory(releaseAssetsRoot);
var manifestPath = Path.Combine(releaseAssetsRoot, "plonds.json");
PayloadUtilities.WriteJson(manifestPath, manifest);
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
return manifestPath;
}
private static PlondsReleasePlatformEntry ReadSummary(string path)
{
var json = File.ReadAllText(path);
var summary = JsonSerializer.Deserialize<PlondsReleasePlatformEntry>(json, PayloadUtilities.JsonOptions);
if (summary is null)
{
throw new InvalidOperationException($"Unable to deserialize PLONDS platform summary: {path}");
}
return summary;
}
}

View File

@@ -1,9 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsReleaseIndexOptions(
string ReleaseTag,
string Version,
string Channel,
string PlatformSummariesDirectory,
string OutputRoot,
string PrivateKeyPath);

View File

@@ -1,38 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace Plonds.Core.Security;
public sealed class RsaFileSigner
{
public string SignFile(string filePath, string privateKeyPath, string? outputPath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Manifest file not found.", filePath);
}
if (!File.Exists(privateKeyPath))
{
throw new FileNotFoundException("Private key PEM file not found.", privateKeyPath);
}
outputPath ??= filePath + ".sig";
var payload = File.ReadAllBytes(filePath);
var privateKeyPem = File.ReadAllText(privateKeyPath, Encoding.ASCII);
if (string.IsNullOrWhiteSpace(privateKeyPem))
{
throw new InvalidOperationException("Private key PEM is empty.");
}
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
var signature = rsa.SignData(payload, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(outputPath, Convert.ToBase64String(signature), Encoding.ASCII);
return outputPath;
}
}

View File

@@ -1,8 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsAssetEntry(
string AssetId,
string FileName,
string Sha256,
long Size,
IReadOnlyList<PlondsMirrorEntry> Mirrors);

View File

@@ -0,0 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record PlondsChangedFileEntry(
string ArchivePath,
string Hash,
long Size,
string HashAlgorithm = "sha256");

View File

@@ -1,11 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsChannelPointer(
string Channel,
string Platform,
string DistributionId,
string Version,
DateTimeOffset PublishedAt,
string? DistributionPath = null,
string? FileMapPath = null);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsComponent(
string Id,
string Root,
string Mode,
IReadOnlyList<PlondsFileEntry> Files,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,14 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsDistributionInfo(
string DistributionId,
string Version,
string Channel,
string Platform,
DateTimeOffset PublishedAt,
IReadOnlyList<PlondsComponent> Components,
IReadOnlyList<PlondsMirrorAsset> InstallerMirrors,
IReadOnlyList<string> Capabilities,
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,13 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record PlondsFileEntry(
string Path,
string Op,
string ContentHash,
string Action,
string Hash,
long Size,
string Mode,
string? ObjectKey = null,
string? Compression = null,
string? PatchBaseHash = null,
string? PatchObjectKey = null);
string HashAlgorithm = "sha256");

View File

@@ -1,13 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsFileMap(
string FormatVersion,
string DistributionId,
string SourceVersion,
string TargetVersion,
string Platform,
IReadOnlyList<PlondsComponent> Components,
IReadOnlyList<string> Capabilities,
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,7 +1,19 @@
namespace Plonds.Shared.Models;
using System.Text.Json.Serialization;
namespace Plonds.Shared.Models;
public sealed record PlondsManifest(
string FormatVersion,
string ReleaseTag,
DateTimeOffset GeneratedAt,
IReadOnlyList<PlondsAssetEntry> Assets);
string CurrentVersion,
string PreviousVersion,
bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
string CompareMethod,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
string? HashAlgorithm,
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums);

View File

@@ -1,10 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsMetadataCatalog(
string ProtocolName,
string ProtocolVersion,
string StorageRoot,
string MetaRoot,
IReadOnlyList<PlondsChannelPointer> Latest,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsMirrorAsset(
string Platform,
string Arch,
string Url,
string? FileName = null,
string? Sha256 = null,
long Size = 0);

View File

@@ -1,5 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsMirrorEntry(
string Type,
string Url);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsReleaseManifest(
string FormatVersion,
string ReleaseTag,
string Version,
string Channel,
DateTimeOffset GeneratedAt,
IReadOnlyList<PlondsReleasePlatformEntry> Platforms);

View File

@@ -1,14 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsReleasePlatformEntry(
string Platform,
string DistributionId,
string? BaselineTag,
string? BaselineVersion,
string TargetVersion,
bool IsFullPayload,
string FilesZipAsset,
string UpdateZipAsset,
string FileMapAsset,
string FileMapSignatureAsset,
string Sha256);

View File

@@ -1,7 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsSignatureDescriptor(
string Algorithm,
string KeyId,
string Signature);

View File

@@ -3,23 +3,39 @@ namespace Plonds.Shared;
public static class PlondsConstants
{
public const string ProtocolName = "PLONDS";
public const string ProtocolVersion = "1.0";
public const string ProtocolVersion = "2.0";
public const string FormatVersion = "2.0";
public const string DefaultApiBasePath = "/api/plonds/v1";
public const string DefaultStorageRoot = "sample-data";
public const string DefaultMetaRoot = "meta";
public const string DefaultRepoRoot = "repo";
public const string DefaultInstallersRoot = "installers";
public const string ActionAdd = "add";
public const string ActionReplace = "replace";
public const string ActionReuse = "reuse";
public const string ActionDelete = "delete";
public const string FileObjectMode = "file-object";
public const string CompressedObjectMode = "compressed-object";
public const string BinaryPatchMode = "binary-patch";
public const string CompareMethodFileCompare = "file-compare";
public const string CompareMethodCommitAnalyze = "commit-analyze";
public static readonly string[] SupportedFileModes =
public const string HashAlgorithmSha256 = "sha256";
public const string HashAlgorithmMd5 = "md5";
public const string DefaultLauncherRelativePath = "LanMountainDesktop.Launcher.exe";
public static readonly string[] SupportedActions =
[
FileObjectMode,
CompressedObjectMode,
BinaryPatchMode
ActionAdd,
ActionReplace,
ActionReuse,
ActionDelete
];
public static readonly string[] SupportedHashAlgorithms =
[
HashAlgorithmSha256,
HashAlgorithmMd5
];
public static readonly string[] SupportedCompareMethods =
[
CompareMethodFileCompare,
CompareMethodCommitAnalyze
];
}

View File

@@ -0,0 +1,9 @@
namespace Plonds.Shared;
public enum PlondsFileAction
{
Add,
Replace,
Reuse,
Delete
}

View File

@@ -1,5 +1,4 @@
using Plonds.Core.Publishing;
using Plonds.Core.Security;
using Plonds.Core.Publishing;
return await PlondsCli.RunAsync(args);
@@ -20,26 +19,14 @@ internal static class PlondsCli
{
switch (command)
{
case "generate":
RunGenerate(options);
return Task.FromResult(0);
case "sign":
RunSign(options);
return Task.FromResult(0);
case "publish":
RunPublish(options);
return Task.FromResult(0);
case "pack-payload":
RunPackPayload(options);
return Task.FromResult(0);
case "build-delta":
RunBuildDelta(options);
return Task.FromResult(0);
case "build-index":
RunBuildIndex(options);
case "build-delta-from-commits":
RunBuildDeltaFromCommits(options);
return Task.FromResult(0);
case "build-plonds":
RunBuildPlonds(options);
case "pack-payload":
RunPackPayload(options);
return Task.FromResult(0);
default:
Console.Error.WriteLine($"Unknown command: {command}");
@@ -54,63 +41,51 @@ internal static class PlondsCli
}
}
private static void RunGenerate(Dictionary<string, string> options)
private static void RunBuildDelta(Dictionary<string, string> options)
{
var generator = new PlondsGenerator();
var result = generator.Generate(new PlondsGenerateOptions(
CurrentVersion: Require(options, "current-version"),
CurrentDirectory: Require(options, "current-dir"),
var builder = new PlondsDeltaBuilder();
var result = builder.Build(new PlondsDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
PreviousVersion: Get(options, "previous-version", "0.0.0") ?? "0.0.0",
PreviousDirectory: Get(options, "previous-dir"),
Channel: Get(options, "channel", "stable") ?? "stable",
DistributionId: Get(options, "distribution-id"),
RepoBaseUrl: Get(options, "repo-base-url"),
FileMapUrl: Get(options, "file-map-url"),
FileMapSignatureUrl: Get(options, "file-map-signature-url"),
InstallerDirectory: Get(options, "installer-directory"),
InstallerBaseUrl: Get(options, "installer-base-url")));
Console.WriteLine($"Generated PLONDS artifacts for {result.Platform}: {result.DistributionId}");
Console.WriteLine(result.FileMapPath);
}
private static void RunSign(Dictionary<string, string> options)
{
var signer = new RsaFileSigner();
var signaturePath = signer.SignFile(
Require(options, "manifest"),
Require(options, "private-key"),
Get(options, "output"));
Console.WriteLine(signaturePath);
}
private static void RunPublish(Dictionary<string, string> options)
{
var publisher = new PlondsPublisher();
var results = publisher.Publish(new PlondsPublishOptions(
Version: Require(options, "version"),
AppArtifactsRoot: Require(options, "app-artifacts-root"),
InstallerArtifactsRoot: Require(options, "installer-artifacts-root"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineRoot: Get(options, "baseline-root"),
RepoBaseUrl: Get(options, "repo-base-url"),
InstallerBaseUrl: Get(options, "installer-base-url"),
IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload",
BaselineVersion: Get(options, "baseline-version"),
BaselineRef: Get(options, "baseline-ref"),
SourceCommit: Get(options, "source-commit"),
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease,
CommitRangeStart: Get(options, "commit-range-start"),
CommitRangeEnd: Get(options, "commit-range-end")));
BaselinePayloadZip: Get(options, "baseline-zip"),
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
foreach (var result in results)
{
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
}
Console.WriteLine($"Built PLONDS delta for {result.Platform}:");
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
Console.WriteLine($" Manifest: {result.ManifestPath}");
}
private static void RunBuildDeltaFromCommits(Dictionary<string, string> options)
{
var builder = new PlondsCommitDeltaBuilder();
var result = builder.Build(new PlondsCommitDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
Channel: Require(options, "channel"),
BaselineTag: Require(options, "baseline-tag"),
CurrentTag: Require(options, "current-tag"),
FallbackBaselineZip: Get(options, "fallback-zip"),
BaselineVersion: Get(options, "baseline-version"),
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
Console.WriteLine($"Built PLONDS commit-delta for {result.Platform}:");
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
Console.WriteLine($" FellBackToFileCompare: {result.FellBackToFileCompare}");
Console.WriteLine($" ChangedSourceFiles: {result.ChangedSourceFiles.Count}");
Console.WriteLine($" MappedArtifactFiles: {result.MappedArtifactFiles.Count}");
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
Console.WriteLine($" Manifest: {result.ManifestPath}");
}
private static void RunPackPayload(Dictionary<string, string> options)
@@ -121,56 +96,6 @@ internal static class PlondsCli
Console.WriteLine(outputZip);
}
private static void RunBuildDelta(Dictionary<string, string> options)
{
var builder = new PlondsDeltaBuilder();
var result = builder.Build(new PlondsDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentTag: Require(options, "current-tag"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineVersion: Get(options, "baseline-version"),
BaselineTag: Get(options, "baseline-tag"),
BaselinePayloadZip: Get(options, "baseline-zip"),
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload,
StaticOutputRoot: Get(options, "static-output-dir"),
UpdateBaseUrl: Get(options, "update-base-url")));
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
Console.WriteLine(result.FileMapPath);
}
private static void RunBuildIndex(Dictionary<string, string> options)
{
var builder = new PlondsReleaseIndexBuilder();
var manifestPath = builder.Build(new PlondsReleaseIndexOptions(
ReleaseTag: Require(options, "release-tag"),
Version: Require(options, "version"),
Channel: Get(options, "channel", "stable") ?? "stable",
PlatformSummariesDirectory: Require(options, "platform-summaries-dir"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key")));
Console.WriteLine(manifestPath);
}
private static void RunBuildPlonds(Dictionary<string, string> options)
{
var builder = new PlondsManifestBuilder();
var manifestPath = builder.Build(new PlondsBuildOptions(
ReleaseTag: Require(options, "release-tag"),
AssetsDirectory: Require(options, "assets-dir"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Repository: Require(options, "repository"),
S3BaseUrl: Get(options, "s3-base-url")));
Console.WriteLine(manifestPath);
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -212,12 +137,8 @@ internal static class PlondsCli
private static void PrintUsage()
{
Console.WriteLine("PLONDS Tool");
Console.WriteLine(" build-delta --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> [--channel <ch>] [--baseline-version <v>] [--baseline-zip <file>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
Console.WriteLine(" build-delta-from-commits --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> --channel <ch> --baseline-tag <tag> --current-tag <tag> [--fallback-zip <file>] [--baseline-version <v>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload] [--static-output-dir <dir>] [--update-base-url <url>]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-plonds --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
}
}

31
check_ipc.cs Normal file
View File

@@ -0,0 +1,31 @@
using System;
using System.Reflection;
using System.Linq;
class Program
{
static void Main()
{
try {
var asm = Assembly.LoadFrom(@"C:\Users\USER154971\.nuget\packages\dotnetcampus.ipc\2.0.0-alpha436\lib\net6.0\dotnetCampus.Ipc.dll");
var type = asm.GetType("dotnetCampus.Ipc.IpcRouteds.DirectRouteds.JsonIpcDirectRoutedProvider");
if (type == null) {
Console.WriteLine("Type not found. Trying to find it...");
foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("JsonIpc"))) {
Console.WriteLine("Found: " + t.FullName);
}
return;
}
Console.WriteLine("Type: " + type.FullName);
foreach (var prop in type.GetProperties()) {
Console.WriteLine("Prop: " + prop.Name + " Type: " + prop.PropertyType.Name);
}
} catch (ReflectionTypeLoadException ex) {
foreach (var e in ex.LoaderExceptions) {
Console.WriteLine("LoaderEx: " + e.Message);
}
} catch (Exception ex) {
Console.WriteLine("Ex: " + ex.Message);
}
}
}

71
size_analysis.ps1 Normal file
View File

@@ -0,0 +1,71 @@
$ErrorActionPreference = 'Continue'
Write-Output "=== FONT FILES ==="
Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets\Fonts' -File | Sort-Object Length -Descending | ForEach-Object {
$sizeKB = [math]::Round($_.Length/1KB, 1)
Write-Output "$sizeKB KB $($_.Name)"
}
$totalFontMB = [math]::Round((Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets\Fonts' -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
Write-Output "TOTAL FONTS: $totalFontMB MB"
Write-Output ""
Write-Output "=== ROOT ASSET FILES ==="
Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets' -File | Sort-Object Length -Descending | ForEach-Object {
$sizeKB = [math]::Round($_.Length/1KB, 1)
Write-Output "$sizeKB KB $($_.Name)"
}
Write-Output ""
Write-Output "=== MATERIAL WEATHER ICONS ==="
$weatherStats = Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets\MaterialWeatherIcons' -Recurse -File | Measure-Object -Property Length -Sum
$weatherMB = [math]::Round($weatherStats.Sum/1MB, 2)
Write-Output "$weatherMB MB total, $($weatherStats.Count) files"
Write-Output ""
Write-Output "=== BUILD OUTPUT (Debug) ==="
$debugPath = 'd:\github\LanMountainDesktop\LanMountainDesktop\bin\Debug'
if (Test-Path $debugPath) {
$debugStats = Get-ChildItem $debugPath -Recurse -File | Measure-Object -Property Length -Sum
$debugMB = [math]::Round($debugStats.Sum/1MB, 2)
Write-Output "$debugMB MB total, $($debugStats.Count) files"
$tfmPath = Get-ChildItem $debugPath -Directory | Select-Object -First 1
if ($tfmPath) {
Write-Output ""
Write-Output "=== TOP 50 LARGEST FILES IN BUILD OUTPUT ==="
Get-ChildItem $debugPath -Recurse -File | Sort-Object Length -Descending | Select-Object -First 50 | ForEach-Object {
$sizeKB = [math]::Round($_.Length/1KB, 1)
$rel = $_.FullName.Substring($debugPath.Length+1)
Write-Output "$sizeKB KB $rel"
}
}
} else {
Write-Output "Debug output not found"
}
Write-Output ""
Write-Output "=== BUILD OUTPUT (Release) ==="
$releasePath = 'd:\github\LanMountainDesktop\LanMountainDesktop\bin\Release'
if (Test-Path $releasePath) {
$releaseStats = Get-ChildItem $releasePath -Recurse -File | Measure-Object -Property Length -Sum
$releaseMB = [math]::Round($releaseStats.Sum/1MB, 2)
Write-Output "$releaseMB MB total, $($releaseStats.Count) files"
} else {
Write-Output "Release output not found"
}
Write-Output ""
Write-Output "=== NUGET CACHE - LARGEST PACKAGES ==="
$nugetPath = 'd:\github\LanMountainDesktop\LanMountainDesktop\obj\project.assets.json'
if (Test-Path $nugetPath) {
$assets = Get-Content $nugetPath -Raw | ConvertFrom-Json
$libs = $assets.Libraries
$libSizes = @()
foreach ($prop in $libs.PSObject.Properties) {
$libSizes += [PSCustomObject]@{
Name = $prop.Name
Type = $prop.Value.type
}
}
$libSizes | Where-Object { $_.Type -eq 'package' } | Select-Object -First 30 Name | ForEach-Object { Write-Output $_.Name }
}

85
size_analysis2.ps1 Normal file
View File

@@ -0,0 +1,85 @@
$ErrorActionPreference = 'Continue'
$base = 'd:\github\LanMountainDesktop\LanMountainDesktop\bin\Debug\net10.0'
Write-Output "=== CATEGORY BREAKDOWN ==="
$categories = @(
@{ Name = "SkiaSharp native (all platforms)"; Pattern = "runtimes\*\native\libSkiaSharp.*" },
@{ Name = "SkiaSharp PDB (all platforms)"; Pattern = "runtimes\*\native\libSkiaSharp.pdb" },
@{ Name = "HarfBuzzSharp native (all platforms)"; Pattern = "runtimes\*\native\libHarfBuzzSharp.*" },
@{ Name = "HarfBuzzSharp PDB (all platforms)"; Pattern = "runtimes\*\native\libHarfBuzzSharp.pdb" },
@{ Name = "SQLite native (all platforms)"; Pattern = "runtimes\*\native\*sqlite3*" },
@{ Name = "WebView2 native"; Pattern = "runtimes\*\native\*WebView2*" },
@{ Name = "Avalonia DLLs"; Pattern = "Avalonia*.dll" },
@{ Name = "FluentAvalonia DLLs"; Pattern = "Fluent*.dll" },
@{ Name = "Material DLLs"; Pattern = "Material*.dll" },
@{ Name = "Sentry DLLs"; Pattern = "Sentry*.dll" },
@{ Name = "PostHog DLLs"; Pattern = "PostHog*.dll" },
@{ Name = "Microsoft.Extensions DLLs"; Pattern = "Microsoft.Extensions*.dll" },
@{ Name = "Microsoft.Data.Sqlite DLLs"; Pattern = "Microsoft.Data*.dll" },
@{ Name = "MudTools DLLs"; Pattern = "MudTools*.dll" },
@{ Name = "PortAudioSharp DLLs"; Pattern = "PortAudio*.dll" },
@{ Name = "Harmony DLLs"; Pattern = "*Harmony*.dll" },
@{ Name = "InkCanvas DLLs"; Pattern = "*InkCanvas*.dll" },
@{ Name = "InkCore DLLs"; Pattern = "*InkCore*.dll" },
@{ Name = "dotnetCampus DLLs"; Pattern = "dotnetCampus*.dll" },
@{ Name = "ClassIsland DLLs"; Pattern = "ClassIsland*.dll" },
@{ Name = "App DLLs (LanMountainDesktop)"; Pattern = "LanMountainDesktop*.dll" }
)
foreach ($cat in $categories) {
$files = Get-ChildItem $base -Recurse -File | Where-Object { $_.Name -like $cat.Pattern -or $_.FullName -like "*\$($cat.Pattern)" }
if (-not $files) {
$files = Get-ChildItem $base -Recurse -File | Where-Object { $_.FullName -like "*$($cat.Pattern)*" }
}
if ($files) {
$totalMB = [math]::Round(($files | Measure-Object -Property Length -Sum).Sum/1MB, 2)
Write-Output "$($cat.Name): $totalMB MB ($($files.Count) files)"
}
}
Write-Output ""
Write-Output "=== RUNTIME RID SUBFOLDERS ==="
Get-ChildItem "$base\runtimes" -Directory | ForEach-Object {
$sizeMB = [math]::Round((Get-ChildItem $_.FullName -Recurse -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
Write-Output "$sizeMB MB $($_.Name)"
}
Write-Output ""
Write-Output "=== AIRAPPHOST RUNTIME RID SUBFOLDERS ==="
$airBase = "$base\AirAppHost\runtimes"
if (Test-Path $airBase) {
Get-ChildItem $airBase -Directory | ForEach-Object {
$sizeMB = [math]::Round((Get-ChildItem $_.FullName -Recurse -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
Write-Output "$sizeMB MB $($_.Name)"
}
}
Write-Output ""
Write-Output "=== TOP-LEVEL DLLs (not in runtimes/) ==="
Get-ChildItem $base -File -Filter "*.dll" | Sort-Object Length -Descending | Select-Object -First 30 | ForEach-Object {
$sizeKB = [math]::Round($_.Length/1KB, 1)
Write-Output "$sizeKB KB $($_.Name)"
}
Write-Output ""
Write-Output "=== TOTAL SIZE BY EXTENSION ==="
Get-ChildItem $base -Recurse -File | Group-Object Extension | Sort-Object Count -Descending | ForEach-Object {
$totalMB = [math]::Round(($_.Group | Measure-Object -Property Length -Sum).Sum/1MB, 2)
Write-Output "$totalMB MB $($_.Count) files $($_.Name)"
}
Write-Output ""
Write-Output "=== AirAppHost duplicate check ==="
$airHostPath = "$base\AirAppHost"
if (Test-Path $airHostPath) {
$airHostMB = [math]::Round((Get-ChildItem $airHostPath -Recurse -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
Write-Output "AirAppHost folder total: $airHostMB MB"
$duplicateFiles = Get-ChildItem $airHostPath -Recurse -File | Where-Object {
$originalPath = Join-Path $base $_.FullName.Substring($airHostPath.Length+1)
(Test-Path $originalPath) -and ((Get-Item $originalPath).Length -eq $_.Length)
}
$dupMB = [math]::Round(($duplicateFiles | Measure-Object -Property Length -Sum).Sum/1MB, 2)
Write-Output "Duplicate files (same name+size as main output): $dupMB MB ($($duplicateFiles.Count) files)"
}