Compare commits

...

3 Commits

Author SHA1 Message Date
lincube
5be4537b2c feat.Arknight endfiled 2026-05-30 13:50:13 +08:00
lincube
c5e75244af feat.PLONDS系统会不断地改进 2026-05-30 13:47:15 +08:00
lincube
6a650873bc feat..去除了冗余的字体文件,又修改了PLONDS系统 2026-05-30 11:56:50 +08:00
85 changed files with 1911 additions and 3149 deletions

View File

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

View File

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

View File

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

View File

@@ -75,9 +75,7 @@ public partial class App : Application
private DispatcherTimer? _shellRecoveryTimer; private DispatcherTimer? _shellRecoveryTimer;
private PluginRuntimeService? _pluginRuntimeService; private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow; private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow;
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow; private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
private bool _isExitingFusedDesktopEditMode;
private bool _mainWindowClosed; private bool _mainWindowClosed;
private DesktopShellHost? _desktopShellHost; private DesktopShellHost? _desktopShellHost;
private PublicIpcHostService? _publicIpcHostService; private PublicIpcHostService? _publicIpcHostService;
@@ -454,22 +452,10 @@ public partial class App : Application
try try
{ {
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate(); FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
fusedDesktopManager.EnterEditMode();
EnsureTransparentOverlayWindow();
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Show();
}
if (_fusedComponentLibraryWindow is { } existingWindow) if (_fusedComponentLibraryWindow is { } existingWindow)
{ {
if (_transparentOverlayWindow is not null)
{
existingWindow.SetOverlayWindow(_transparentOverlayWindow);
}
if (!existingWindow.IsVisible) if (!existingWindow.IsVisible)
{ {
existingWindow.Show(); existingWindow.Show();
@@ -477,7 +463,7 @@ public partial class App : Application
if (centerInWorkArea) if (centerInWorkArea)
{ {
existingWindow.CenterInWorkArea(_transparentOverlayWindow); existingWindow.CenterInWorkArea();
} }
existingWindow.Activate(); existingWindow.Activate();
@@ -486,16 +472,12 @@ public partial class App : Application
var window = new FusedDesktopComponentLibraryWindow(); var window = new FusedDesktopComponentLibraryWindow();
_fusedComponentLibraryWindow = window; _fusedComponentLibraryWindow = window;
if (_transparentOverlayWindow is not null)
{
window.SetOverlayWindow(_transparentOverlayWindow);
}
window.Closed += OnFusedComponentLibraryWindowClosed; window.Closed += OnFusedComponentLibraryWindowClosed;
window.Show(); window.Show();
if (centerInWorkArea) if (centerInWorkArea)
{ {
window.CenterInWorkArea(_transparentOverlayWindow); window.CenterInWorkArea();
} }
window.Activate(); window.Activate();
@@ -503,7 +485,13 @@ public partial class App : Application
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", 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; _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 try
{ {
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
} }
catch (Exception exitEx) catch (Exception ex)
{ {
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx); AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode after library closed.", ex);
}
}
finally
{
_isExitingFusedDesktopEditMode = false;
} }
} }
@@ -890,11 +841,6 @@ public partial class App : Application
{ {
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'."); AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source); var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation(); mainWindow.PrepareEnterAnimation();
@@ -939,26 +885,6 @@ public partial class App : Application
} }
} }
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) internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
{ {
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
@@ -1263,31 +1189,16 @@ public partial class App : Application
finally finally
{ {
_fusedComponentLibraryWindow = null; _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(); FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex); AppLogger.Warn("FusedDesktop", "Failed to shut down fused desktop manager during exit cleanup.", ex);
}
finally
{
_transparentOverlayWindow = null;
}
} }
AudioRecorderServiceFactory.DisposeSharedServices(); AudioRecorderServiceFactory.DisposeSharedServices();
@@ -1572,13 +1483,6 @@ public partial class App : Application
AppLogger.Info( AppLogger.Info(
"DesktopShell", "DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'."); $"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) catch (Exception ex)
{ {
@@ -1668,7 +1572,6 @@ public partial class App : Application
if (IsMainWindowDesktopLayerEnabled()) if (IsMainWindowDesktopLayerEnabled())
{ {
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown(); FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
_mainWindow.ShowInTaskbar = false; _mainWindow.ShowInTaskbar = false;
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow); _mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
@@ -1697,7 +1600,6 @@ public partial class App : Application
return; return;
} }
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown(); FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
} }
catch (Exception ex) 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
@@ -11,28 +12,24 @@ using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
/// <summary>
/// 融合桌面中央管理器服务接口
/// </summary>
public interface IFusedDesktopManagerService public interface IFusedDesktopManagerService
{ {
void Initialize(); void Initialize();
void EnterEditMode();
void ExitEditMode();
void ReloadWidgets(); void ReloadWidgets();
void Shutdown(); void Shutdown();
void AddComponent(string componentId);
void RemoveComponent(string placementId);
void EnterEditMode();
void ExitEditMode();
bool IsEditMode { get; }
} }
/// <summary>
/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。
/// </summary>
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
{ {
private readonly IFusedDesktopLayoutService _layoutService; private readonly IFusedDesktopLayoutService _layoutService;
private readonly ISettingsFacadeService _settingsFacade; private readonly ISettingsFacadeService _settingsFacade;
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = []; private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
// 基础服务依赖
private readonly IWeatherInfoService _weatherDataService; private readonly IWeatherInfoService _weatherDataService;
private readonly TimeZoneService _timeZoneService; private readonly TimeZoneService _timeZoneService;
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
@@ -43,6 +40,10 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
private bool _isEditMode; private bool _isEditMode;
private const double DefaultCellSize = 100; private const double DefaultCellSize = 100;
private const double DefaultComponentWidth = 200;
private const double DefaultComponentHeight = 200;
public bool IsEditMode => _isEditMode;
public FusedDesktopManagerService( public FusedDesktopManagerService(
IFusedDesktopLayoutService layoutService, IFusedDesktopLayoutService layoutService,
@@ -59,7 +60,6 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
{ {
if (!OperatingSystem.IsWindows()) return; if (!OperatingSystem.IsWindows()) return;
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App); var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (!appSnapshot.EnableFusedDesktop) if (!appSnapshot.EnableFusedDesktop)
{ {
@@ -88,12 +88,12 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (_isEditMode) return; if (_isEditMode) return;
_isEditMode = true; _isEditMode = true;
// 【修复问题3】不再隐藏窗口而是将窗口内容转移到编辑模式覆盖层
// 这样可以保持组件的运行状态(动画、输入等)
foreach (var window in _widgetWindows.Values) foreach (var window in _widgetWindows.Values)
{ {
window.Hide(); window.SetEditMode(true);
} }
AppLogger.Info("FusedDesktop", "Entered edit mode.");
} }
public void ExitEditMode() public void ExitEditMode()
@@ -101,14 +101,81 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (!_isEditMode) return; if (!_isEditMode) return;
_isEditMode = false; _isEditMode = false;
// 编辑完成,重新加载布局(可能已发生更改)并显示 foreach (var window in _widgetWindows.Values)
ReloadWidgets(); {
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() public void ReloadWidgets()
{ {
if (_isEditMode) return; // 编辑模式下不渲染小窗口
var layout = _layoutService.Load(); var layout = _layoutService.Load();
var existingIds = new HashSet<string>(_widgetWindows.Keys); var existingIds = new HashSet<string>(_widgetWindows.Keys);
@@ -118,8 +185,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow)) if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
{ {
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。 existingWindow.Position = new PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.UpdateComponentLayout(placement.Width, placement.Height); existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
if (existingWindow.IsVisible == false) if (existingWindow.IsVisible == false)
{ {
@@ -130,15 +196,19 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
} }
else else
{ {
// 新组件,生成窗口
try try
{ {
var window = CreateWidgetWindow(placement); var window = CreateWidgetWindow(placement);
if (window != null) if (window != null)
{ {
_widgetWindows[placement.PlacementId] = window; _widgetWindows[placement.PlacementId] = window;
if (_isEditMode)
{
window.SetEditMode(true);
}
window.Show(); 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(); window.RefreshDesktopLayer();
} }
} }
@@ -149,7 +219,6 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
} }
} }
// 移除被删除的组件
foreach (var id in existingIds) foreach (var id in existingIds)
{ {
if (_widgetWindows.Remove(id, out var windowToRemove)) if (_widgetWindows.Remove(id, out var windowToRemove))
@@ -189,18 +258,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
_settingsFacade, _settingsFacade,
placement.PlacementId); placement.PlacementId);
// 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度)
control.Width = placement.Width; control.Width = placement.Width;
control.Height = placement.Height; control.Height = placement.Height;
var window = new DesktopWidgetWindow(control); var window = new DesktopWidgetWindow(control, placement.PlacementId);
return window; return window;
} }
} }
/// <summary>
/// 工厂
/// </summary>
public static class FusedDesktopManagerServiceFactory public static class FusedDesktopManagerServiceFactory
{ {
private static IFusedDesktopManagerService? _instance; private static IFusedDesktopManagerService? _instance;

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
@@ -12,6 +13,13 @@ public partial class DesktopWidgetWindow : Window
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.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() public DesktopWidgetWindow()
{ {
InitializeComponent(); 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; 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) public void UpdateComponentLayout(double width, double height)
{ {
ComponentContainer.Width = width; 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() private void UpdateInteractiveRegion()
{ {
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect> _regionPassthroughService.SetInteractiveRegions(this, new List<Rect>

View File

@@ -12,8 +12,6 @@ namespace LanMountainDesktop.Views;
public partial class FusedDesktopComponentLibraryWindow : Window public partial class FusedDesktopComponentLibraryWindow : Window
{ {
private TransparentOverlayWindow? _overlayWindow;
public FusedDesktopComponentLibraryWindow() public FusedDesktopComponentLibraryWindow()
{ {
InitializeComponent(); InitializeComponent();
@@ -45,13 +43,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window
RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component; RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component;
} }
public bool PreserveEditModeOnClose { get; private set; }
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
{
_overlayWindow = overlayWindow;
}
public void CenterInWorkArea(Window? referenceWindow = null) public void CenterInWorkArea(Window? referenceWindow = null)
{ {
var screen = referenceWindow is not null var screen = referenceWindow is not null
@@ -74,22 +65,13 @@ public partial class FusedDesktopComponentLibraryWindow : Window
private void OnAddComponentRequested(object? sender, string componentId) private void OnAddComponentRequested(object? sender, string componentId)
{ {
if (_overlayWindow is null) FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId);
{ AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' directly to fused desktop.");
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;
Close(); Close();
} }
private void OnCloseClick(object? sender, RoutedEventArgs e) private void OnCloseClick(object? sender, RoutedEventArgs e)
{ {
PreserveEditModeOnClose = false;
Close(); Close();
} }
@@ -105,7 +87,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window
{ {
if (e.Key == Key.Escape) if (e.Key == Key.Escape)
{ {
PreserveEditModeOnClose = false;
Close(); 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,14 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsCommitDeltaBuildOptions(
string Platform,
string CurrentVersion,
string CurrentPayloadZip,
string OutputRoot,
string Channel,
string BaselineTag,
string CurrentTag,
string HashAlgorithm = "sha256",
string? SourceDirs = null,
string? FallbackBaselineZip = null,
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe");

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,338 @@
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
};
private static readonly Dictionary<string, string[]> SourceToArtifactMap = new(StringComparer.OrdinalIgnoreCase)
{
["LanMountainDesktop"] = ["LanMountainDesktop.dll", "LanMountainDesktop.exe"],
["LanMountainDesktop.Launcher"] = ["LanMountainDesktop.Launcher.exe"],
["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[] FallbackAllArtifacts =
[
"LanMountainDesktop.dll",
"LanMountainDesktop.exe",
"LanMountainDesktop.Launcher.exe",
"LanMountainDesktop.Shared.Contracts.dll",
"LanMountainDesktop.PluginSdk.dll",
"LanMountainDesktop.Appearance.dll",
"LanMountainDesktop.Settings.Core.dll",
"LanMountainDesktop.ComponentSystem.dll"
];
public PlondsDeltaBuildResult Build(PlondsCommitDeltaBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var hashAlgorithm = ValidateHashAlgorithm(options.HashAlgorithm);
var sourceDirs = ParseSourceDirs(options.SourceDirs);
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 = GetChangedSourceFiles(options.BaselineTag, options.CurrentTag, sourceDirs);
if (changedSourceFiles.Count == 0)
{
Console.WriteLine("No source code changes detected between tags. Falling back to file-compare method.");
return FallbackToFileCompare(options, currentExtractRoot, outputRoot, hashAlgorithm);
}
Console.WriteLine($"Detected {changedSourceFiles.Count} changed source file(s) between {options.BaselineTag} and {options.CurrentTag}.");
foreach (var file in changedSourceFiles.Take(20))
{
Console.WriteLine($" {file}");
}
if (changedSourceFiles.Count > 20)
{
Console.WriteLine($" ... and {changedSourceFiles.Count - 20} more");
}
var artifactFiles = MapSourceToArtifacts(changedSourceFiles, sourceDirs);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var artifactFile in artifactFiles)
{
var normalizedPath = artifactFile.Replace('\\', '/');
if (!currentManifest.TryGetValue(normalizedPath, out var fingerprint))
{
Console.WriteLine($" Artifact not found in current zip: {normalizedPath}, skipping.");
continue;
}
var fileHash = PlondsDeltaBuilder.ComputeHash(fingerprint.FullPath, hashAlgorithm);
var action = PlondsConstants.ActionReplace;
filesMap[normalizedPath] = new PlondsFileEntry(action, fileHash, fingerprint.Size, hashAlgorithm);
changedFilesMap[normalizedPath] = new PlondsChangedFileEntry(normalizedPath, fileHash, fingerprint.Size, hashAlgorithm);
}
var changedZipPath = CreateChangedZipFromList(currentExtractRoot, artifactFiles, outputRoot, options.Platform);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var launcherInChanges = artifactFiles.Any(f =>
string.Equals(Path.GetFileName(f), "LanMountainDesktop.Launcher.exe", StringComparison.OrdinalIgnoreCase));
var manifest = new PlondsManifest(
FormatVersion: PlondsConstants.FormatVersion,
CurrentVersion: options.CurrentVersion,
PreviousVersion: options.BaselineTag.TrimStart('v'),
IsFullUpdate: false,
RequiresCleanInstall: launcherInChanges,
Channel: options.Channel,
Platform: options.Platform,
UpdatedAt: DateTimeOffset.UtcNow,
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 PlondsDeltaBuildResult(
Platform: options.Platform,
ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
IsFullUpdate: false,
RequiresCleanInstall: launcherInChanges,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineTag.TrimStart('v'));
}
private static List<string> GetChangedSourceFiles(string baselineTag, string currentTag, string[] sourceDirs)
{
var changedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var normalizedBaseline = baselineTag.StartsWith("v") ? baselineTag : $"v{baselineTag}";
var normalizedCurrent = currentTag.StartsWith("v") ? currentTag : $"v{currentTag}";
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "git",
Arguments = $"diff --name-only {normalizedBaseline}..{normalizedCurrent}",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException("Failed to start git process.");
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"git diff failed with exit code {process.ExitCode}.");
}
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
continue;
}
var isSourceFile = sourceDirs.Any(dir =>
trimmed.StartsWith(dir + "/", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith(dir + "\\", StringComparison.OrdinalIgnoreCase));
if (isSourceFile)
{
changedFiles.Add(trimmed);
}
}
return changedFiles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
private static HashSet<string> MapSourceToArtifacts(IReadOnlyList<string> changedSourceFiles, string[] sourceDirs)
{
var artifacts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var hasUnmappedChanges = false;
foreach (var sourceFile in changedSourceFiles)
{
var mapped = false;
foreach (var dir in sourceDirs)
{
if (!sourceFile.StartsWith(dir + "/", StringComparison.OrdinalIgnoreCase) &&
!sourceFile.StartsWith(dir + "\\", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (SourceToArtifactMap.TryGetValue(dir, out var artifactList))
{
foreach (var artifact in artifactList)
{
artifacts.Add(artifact);
}
mapped = true;
break;
}
}
if (!mapped)
{
var extension = Path.GetExtension(sourceFile).ToLowerInvariant();
if (extension is ".json" or ".xml" or ".config")
{
var fileName = Path.GetFileName(sourceFile);
artifacts.Add(fileName);
mapped = true;
}
}
if (!mapped)
{
hasUnmappedChanges = true;
}
}
if (hasUnmappedChanges)
{
Console.WriteLine("Unmapped source changes detected. Including all core artifacts as a conservative fallback.");
foreach (var artifact in FallbackAllArtifacts)
{
artifacts.Add(artifact);
}
}
return artifacts;
}
private static PlondsDeltaBuildResult FallbackToFileCompare(
PlondsCommitDeltaBuildOptions options,
string currentExtractRoot,
string outputRoot,
string hashAlgorithm)
{
var fallbackZip = string.IsNullOrWhiteSpace(options.FallbackBaselineZip)
? null
: Path.GetFullPath(options.FallbackBaselineZip);
if (string.IsNullOrWhiteSpace(fallbackZip) || !File.Exists(fallbackZip))
{
Console.WriteLine("No fallback baseline zip available. Generating full update.");
var fullBuilder = new PlondsDeltaBuilder();
return fullBuilder.Build(new PlondsDeltaBuildOptions(
Platform: options.Platform,
CurrentVersion: options.CurrentVersion,
CurrentPayloadZip: options.CurrentPayloadZip,
OutputRoot: outputRoot,
Channel: options.Channel,
HashAlgorithm: hashAlgorithm,
LauncherRelativePath: options.LauncherRelativePath));
}
Console.WriteLine($"Falling back to file-compare using baseline: {fallbackZip}");
var deltaBuilder = new PlondsDeltaBuilder();
return deltaBuilder.Build(new PlondsDeltaBuildOptions(
Platform: options.Platform,
CurrentVersion: options.CurrentVersion,
CurrentPayloadZip: options.CurrentPayloadZip,
OutputRoot: outputRoot,
Channel: options.Channel,
BaselineVersion: options.BaselineTag.TrimStart('v'),
BaselinePayloadZip: fallbackZip,
HashAlgorithm: hashAlgorithm,
LauncherRelativePath: options.LauncherRelativePath));
}
private static string CreateChangedZipFromList(
string currentExtractRoot,
IEnumerable<string> artifactFiles,
string outputRoot,
string platform)
{
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
foreach (var artifactFile in artifactFiles)
{
var normalizedPath = artifactFile.Replace('\\', '/');
var sourcePath = Path.Combine(currentExtractRoot, normalizedPath);
if (!File.Exists(sourcePath))
{
continue;
}
var destPath = Path.Combine(stagingRoot, normalizedPath);
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 ValidateHashAlgorithm(string algorithm)
{
var normalized = algorithm.Trim().ToLowerInvariant();
if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5))
{
throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5");
}
return normalized;
}
private static string[] ParseSourceDirs(string? sourceDirs)
{
if (string.IsNullOrWhiteSpace(sourceDirs))
{
return PlondsConstants.DefaultSourceDirs;
}
return sourceDirs.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
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( public sealed record PlondsDeltaBuildOptions(
string Platform, string Platform,
string CurrentVersion, string CurrentVersion,
string CurrentTag,
string CurrentPayloadZip, string CurrentPayloadZip,
string OutputRoot, string OutputRoot,
string PrivateKeyPath,
string Channel = "stable", string Channel = "stable",
string? BaselineVersion = null, string? BaselineVersion = null,
string? BaselineTag = null,
string? BaselinePayloadZip = null, string? BaselinePayloadZip = null,
bool IsFullPayload = false, string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
string? StaticOutputRoot = null, string HashAlgorithm = "sha256");
string? UpdateBaseUrl = null);

View File

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

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; using Plonds.Shared.Models;
namespace Plonds.Core.Publishing; namespace Plonds.Core.Publishing;
public sealed class PlondsDeltaBuilder 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) public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
var hashAlgorithm = ValidateHashAlgorithm(options.HashAlgorithm);
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip); var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
if (!File.Exists(currentPayloadZip)) if (!File.Exists(currentPayloadZip))
{ {
@@ -29,332 +37,197 @@ public sealed class PlondsDeltaBuilder
var workRoot = Path.Combine(outputRoot, "work", options.Platform); var workRoot = Path.Combine(outputRoot, "work", options.Platform);
var currentExtractRoot = Path.Combine(workRoot, "current"); var currentExtractRoot = Path.Combine(workRoot, "current");
var baselineExtractRoot = Path.Combine(workRoot, "baseline"); 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(outputRoot);
Directory.CreateDirectory(summaryRoot);
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot); PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip); var isFullUpdate = string.IsNullOrWhiteSpace(baselinePayloadZip);
if (useFullPayload) if (!isFullUpdate)
{
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
}
else
{ {
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot); PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
} }
PayloadUtilities.EnsureCleanDirectory(objectsRoot); var previousManifest = isFullUpdate
var previousManifest = useFullPayload
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase) ? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: PayloadUtilities.ScanDirectory(baselineExtractRoot); : PayloadUtilities.ScanDirectory(baselineExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot); 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 filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm);
var fileMapAssetName = $"plonds-filemap-{options.Platform}.json"; var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm);
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);
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath); var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) var launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath);
{ var requiresCleanInstall = launcherChanged && !isFullUpdate;
["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 generatedAt = DateTimeOffset.UtcNow; var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var component = new ComponentDocument(
Name: "app",
Version: options.CurrentVersion,
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["component"] = "app",
["mode"] = "file-object"
},
Files: fileEntries);
var fileMap = new FileMapDocument( var manifest = new PlondsManifest(
FormatVersion: "1.0", FormatVersion: PlondsConstants.FormatVersion,
DistributionId: distributionId, CurrentVersion: options.CurrentVersion,
FromVersion: options.BaselineVersion ?? "0.0.0", PreviousVersion: options.BaselineVersion ?? "0.0.0",
ToVersion: options.CurrentVersion, IsFullUpdate: isFullUpdate,
Version: options.CurrentVersion, RequiresCleanInstall: requiresCleanInstall,
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",
Channel: options.Channel, Channel: options.Channel,
Platform: options.Platform, Platform: options.Platform,
Arch: PayloadUtilities.ResolveArch(options.Platform), UpdatedAt: DateTimeOffset.UtcNow,
PublishedAt: generatedAt, FilesMap: filesMap,
FileMapUrl: fileMapUrl, ChangedFilesMap: changedFilesMap,
FileMapSignatureUrl: fileMapUrl + ".sig", Checksums: new Dictionary<string, string>
Components: [component],
InstallerMirrors: [],
Capabilities: ["file-object"],
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["protocol"] = "PLONDS", ["changed.zip"] = $"md5:{changedZipMd5}"
["releaseTag"] = options.CurrentTag,
["baselineTag"] = options.BaselineTag ?? string.Empty,
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
["targetVersion"] = options.CurrentVersion,
["isFullPayload"] = options.IsFullPayload ? "true" : "false"
}); });
var latest = new LatestPointerDocument( var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
DistributionId: distributionId, var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
Version: options.CurrentVersion, File.WriteAllText(manifestPath, manifestJson);
Channel: options.Channel,
return new PlondsDeltaBuildResult(
Platform: options.Platform, Platform: options.Platform,
PublishedAt: generatedAt); ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution); IsFullUpdate: isFullUpdate,
PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest); RequiresCleanInstall: requiresCleanInstall,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineVersion);
} }
private static void CopyDirectory(string sourceDir, string destinationDir) private static string ValidateHashAlgorithm(string algorithm)
{ {
Directory.CreateDirectory(destinationDir); var normalized = algorithm.Trim().ToLowerInvariant();
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories)) if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5))
{ {
var relativePath = Path.GetRelativePath(sourceDir, directory); throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5");
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
} }
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) return normalized;
}
private static Dictionary<string, PlondsFileEntry> BuildFilesMap(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string hashAlgorithm)
{ {
var relativePath = Path.GetRelativePath(sourceDir, file); var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
File.Copy(file, destinationPath, overwrite: true); {
var current = currentManifest[path];
var currentHash = ComputeHash(current.FullPath, hashAlgorithm);
if (previousManifest.TryGetValue(path, out var previous))
{
var previousHash = ComputeHash(previous.FullPath, hashAlgorithm);
if (string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase))
{
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReuse, currentHash, current.Size, hashAlgorithm);
continue;
}
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReplace, currentHash, current.Size, hashAlgorithm);
continue;
}
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionAdd, currentHash, current.Size, hashAlgorithm);
}
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 FileMapDocument( return filesMap;
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 sealed record ComponentDocument( private static Dictionary<string, PlondsChangedFileEntry> BuildChangedFilesMap(
string Name, IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string Version, string hashAlgorithm)
IReadOnlyDictionary<string, string>? Metadata, {
IReadOnlyList<FileEntryDocument> Files); var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
private sealed record FileEntryDocument( foreach (var (path, entry) in filesMap)
string Path, {
string Action, if (entry.Action is PlondsConstants.ActionAdd or PlondsConstants.ActionReplace)
string Sha256, {
long Size, changedFilesMap[path] = new PlondsChangedFileEntry(path, entry.Hash, entry.Size, hashAlgorithm);
string? ObjectPath, }
string? ObjectKey, }
string? ObjectUrl,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record DistributionDocument( return changedFilesMap;
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);
private sealed record LatestPointerDocument( private static string CreateChangedZip(
string DistributionId, string currentExtractRoot,
string Version, IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string Channel, string outputRoot,
string Platform, string platform)
DateTimeOffset PublishedAt); {
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
private sealed record InstallerMirrorDocument( foreach (var (path, entry) in filesMap)
string Platform, {
string? Url, if (entry.Action is not (PlondsConstants.ActionAdd or PlondsConstants.ActionReplace))
string? FileName, {
string? Sha256, continue;
long Size); }
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);
}
internal static string ComputeHash(string filePath, string hashAlgorithm)
{
using var stream = File.OpenRead(filePath);
var hash = hashAlgorithm == PlondsConstants.HashAlgorithmMd5
? MD5.HashData(stream)
: SHA256.HashData(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
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; namespace Plonds.Shared.Models;
public sealed record PlondsFileEntry( public sealed record PlondsFileEntry(
string Path, string Action,
string Op, string Hash,
string ContentHash,
long Size, long Size,
string Mode, string HashAlgorithm = "sha256");
string? ObjectKey = null,
string? Compression = null,
string? PatchBaseHash = null,
string? PatchObjectKey = null);

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,14 @@
namespace Plonds.Shared.Models; namespace Plonds.Shared.Models;
public sealed record PlondsManifest( public sealed record PlondsManifest(
string FormatVersion, string FormatVersion,
string ReleaseTag, string CurrentVersion,
DateTimeOffset GeneratedAt, string PreviousVersion,
IReadOnlyList<PlondsAssetEntry> Assets); bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
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,44 @@ namespace Plonds.Shared;
public static class PlondsConstants public static class PlondsConstants
{ {
public const string ProtocolName = "PLONDS"; 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 ActionAdd = "add";
public const string DefaultStorageRoot = "sample-data"; public const string ActionReplace = "replace";
public const string DefaultMetaRoot = "meta"; public const string ActionReuse = "reuse";
public const string DefaultRepoRoot = "repo"; public const string ActionDelete = "delete";
public const string DefaultInstallersRoot = "installers";
public const string FileObjectMode = "file-object"; public const string HashAlgorithmSha256 = "sha256";
public const string CompressedObjectMode = "compressed-object"; public const string HashAlgorithmMd5 = "md5";
public const string BinaryPatchMode = "binary-patch";
public static readonly string[] SupportedFileModes = public const string DefaultLauncherRelativePath = "LanMountainDesktop.Launcher.exe";
public const string CompareMethodFileCompare = "file-compare";
public const string CompareMethodCommitAnalyze = "commit-analyze";
public static readonly string[] SupportedActions =
[ [
FileObjectMode, ActionAdd,
CompressedObjectMode, ActionReplace,
BinaryPatchMode ActionReuse,
ActionDelete
];
public static readonly string[] SupportedHashAlgorithms =
[
HashAlgorithmSha256,
HashAlgorithmMd5
];
public static readonly string[] DefaultSourceDirs =
[
"LanMountainDesktop",
"LanMountainDesktop.Launcher",
"LanMountainDesktop.Shared.Contracts",
"LanMountainDesktop.PluginSdk",
"LanMountainDesktop.Appearance",
"LanMountainDesktop.Settings.Core",
"LanMountainDesktop.ComponentSystem"
]; ];
} }

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.Publishing;
using Plonds.Core.Security;
return await PlondsCli.RunAsync(args); return await PlondsCli.RunAsync(args);
@@ -20,26 +19,14 @@ internal static class PlondsCli
{ {
switch (command) 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": case "build-delta":
RunBuildDelta(options); RunBuildDelta(options);
return Task.FromResult(0); return Task.FromResult(0);
case "build-index": case "build-delta-from-commits":
RunBuildIndex(options); RunBuildDeltaFromCommits(options);
return Task.FromResult(0); return Task.FromResult(0);
case "build-plonds": case "pack-payload":
RunBuildPlonds(options); RunPackPayload(options);
return Task.FromResult(0); return Task.FromResult(0);
default: default:
Console.Error.WriteLine($"Unknown command: {command}"); Console.Error.WriteLine($"Unknown command: {command}");
@@ -54,63 +41,48 @@ 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 builder = new PlondsDeltaBuilder();
var result = generator.Generate(new PlondsGenerateOptions( var result = builder.Build(new PlondsDeltaBuildOptions(
CurrentVersion: Require(options, "current-version"),
CurrentDirectory: Require(options, "current-dir"),
Platform: Require(options, "platform"), Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"), 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", 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"), BaselineVersion: Get(options, "baseline-version"),
BaselineRef: Get(options, "baseline-ref"), BaselinePayloadZip: Get(options, "baseline-zip"),
SourceCommit: Get(options, "source-commit"), LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease, HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
CommitRangeStart: Get(options, "commit-range-start"),
CommitRangeEnd: Get(options, "commit-range-end")));
foreach (var result in results) Console.WriteLine($"Built PLONDS delta for {result.Platform}:");
{ Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
Console.WriteLine($"{result.Platform}: {result.DistributionId}"); 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"),
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256",
SourceDirs: Get(options, "source-dirs"),
FallbackBaselineZip: Get(options, "fallback-zip"),
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe"));
Console.WriteLine($"Built PLONDS commit-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 RunPackPayload(Dictionary<string, string> options) private static void RunPackPayload(Dictionary<string, string> options)
@@ -121,56 +93,6 @@ internal static class PlondsCli
Console.WriteLine(outputZip); 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) private static Dictionary<string, string> ParseOptions(string[] args)
{ {
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -212,12 +134,34 @@ internal static class PlondsCli
private static void PrintUsage() private static void PrintUsage()
{ {
Console.WriteLine("PLONDS Tool"); Console.WriteLine("PLONDS Tool");
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>"); Console.WriteLine();
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("Commands:");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]"); Console.WriteLine(" build-delta Build delta by comparing two payload zips");
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(" --platform <platform> Platform identifier (e.g. windows-x64)");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]"); Console.WriteLine(" --current-version <v> Current release version");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]"); Console.WriteLine(" --current-zip <file> Current payload zip path");
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]"); Console.WriteLine(" --output-dir <dir> Output directory");
Console.WriteLine(" [--channel <ch>] Update channel (default: stable)");
Console.WriteLine(" [--baseline-version <v>] Baseline version");
Console.WriteLine(" [--baseline-zip <file>] Baseline payload zip path");
Console.WriteLine(" [--hash-algorithm <alg>] sha256 or md5 (default: sha256)");
Console.WriteLine(" [--launcher-path <path>] Launcher exe relative path");
Console.WriteLine();
Console.WriteLine(" build-delta-from-commits Build delta by analyzing git commits");
Console.WriteLine(" --platform <platform> Platform identifier");
Console.WriteLine(" --current-version <v> Current release version");
Console.WriteLine(" --current-zip <file> Current payload zip path");
Console.WriteLine(" --output-dir <dir> Output directory");
Console.WriteLine(" --channel <ch> Update channel");
Console.WriteLine(" --baseline-tag <tag> Baseline git tag");
Console.WriteLine(" --current-tag <tag> Current git tag");
Console.WriteLine(" [--hash-algorithm <alg>] sha256 or md5 (default: sha256)");
Console.WriteLine(" [--source-dirs <dirs>] Comma-separated source dirs to analyze");
Console.WriteLine(" [--fallback-zip <file>] Baseline zip for fallback to file-compare");
Console.WriteLine(" [--launcher-path <path>] Launcher exe relative path");
Console.WriteLine();
Console.WriteLine(" pack-payload Pack a directory into a payload zip");
Console.WriteLine(" --source-dir <dir> Source directory");
Console.WriteLine(" --output-zip <file> Output zip path");
} }
} }

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);
}
}
}

View File

@@ -0,0 +1,99 @@
# Git 提交分析报告
## 基本信息
- **哈希**: 6a650873bc4ec18c59abcbde8a778ce82b3c42fc
- **短哈希**: 6a65087
- **作者**: lincube &lt;lincube3@hotmail.com&gt;
- **时间**: 2026-05-30 11:56:50 +0800
## 提交信息摘要
feat..去除了冗余的字体文件又修改了PLONDS系统
## 变更统计
| 指标 | 数值 |
|------|------|
| 变更文件数 | 23 |
| 新增行数 | 898 |
| 删除行数 | 357 |
| 净变化 | +541 |
## 详细变更分析
### 新增文件
1. `CheckIpcAot/CheckIpcAot.csproj` - 新增项目文件
2. `CheckIpcAot/Program.cs` - 新增程序入口
3. `.trae/specs/plonds-comparator-redesign/spec.md` - PLONDS 比较器重设计规格文档
### 删除文件
1. `LanMountainDesktop/Assets/Fonts/MiSans-Bold.otf` - MiSans 粗体字体
2. `LanMountainDesktop/Assets/Fonts/MiSans-Demibold.otf` - MiSans 特粗字体
3. `LanMountainDesktop/Assets/Fonts/MiSans-ExtraLight.otf` - MiSans 特细字体
4. `LanMountainDesktop/Assets/Fonts/MiSans-Heavy.otf` - MiSans 重字体
5. `LanMountainDesktop/Assets/Fonts/MiSans-Light.otf` - MiSans 细字体
6. `LanMountainDesktop/Assets/Fonts/MiSans-Medium.otf` - MiSans 中等字体
7. `LanMountainDesktop/Assets/Fonts/MiSans-Normal.otf` - MiSans 常规字体
8. `LanMountainDesktop/Assets/Fonts/MiSans-Regular.otf` - MiSans 标准字体
9. `LanMountainDesktop/Assets/Fonts/MiSans-Semibold.otf` - MiSans 半粗字体
10. `LanMountainDesktop/Assets/Fonts/MiSans-Thin.otf` - MiSans 纤细字体
11. `LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md` - MiSans 字体说明文档
### 主要变更文件
1. `.github/workflows/plonds-comparator.yml` - PLONDS 比较器工作流更新
2. `LanMountainDesktop/App.axaml.cs` - 应用入口文件精简
3. `LanMountainDesktop/Services/FusedDesktopManagerService.cs` - 融合桌面管理器服务
4. `LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs` - 桌面组件窗口
5. `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs` - 融合桌面组件库窗口
6. `LanMountainDesktop.Launcher/AppJsonContext.cs` - Launcher JSON 上下文
7. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props` - Launcher AOT 属性
8. `LanMountainDesktop.Tests/WindowLayerIsolationTests.cs` - 窗口层隔离测试(删除)
### 主要变更点
#### 1. 字体文件清理
- **删除所有 MiSans 字体文件**:移除了 10 个不同字重的 MiSans 字体文件Bold、Demibold、ExtraLight、Heavy、Light、Medium、Normal、Regular、Semibold、Thin
- **删除字体说明文档**:移除了 MiSans-NOTICE.md
- **潜在目的**:减小应用体积,可能改用系统字体或其他字体方案
#### 2. PLONDS 系统改进
- **工作流更新**`.github/workflows/plonds-comparator.yml` 有 138 行新增157 行删除
- 移除了 `edited` 事件类型触发
- 工作流名称保持不变
- **新增规格文档**`.trae/specs/plonds-comparator-redesign/spec.md` 有 512 行新内容,描述了 PLONDS 比较器的重设计
#### 3. 桌面组件系统调整
- **FusedDesktopManagerService.cs**106 行新增41 行删除
- **DesktopWidgetWindow.axaml.cs**135 行新增1 行删除
- **FusedDesktopComponentLibraryWindow.axaml.cs**23 行变更
- **App.axaml.cs**19 行新增117 行删除(大幅精简)
#### 4. 新增 CheckIpcAot 项目
- 新增独立的项目 `CheckIpcAot`,包含项目文件和简单的 Program.cs
- 可能用于测试 IPC 的 AOT 兼容性
#### 5. Launcher 相关调整
- `AppJsonContext.cs` 新增 5 行
- AOT 配置文件有小幅变更
#### 6. 测试清理
- 删除了 `WindowLayerIsolationTests.cs` 中的 5 行测试代码
## 代码审查要点
### 优势
1. **体积优化**:删除大量字体文件可显著减小应用体积
2. **PLONDS 改进**:工作流优化和重设计规格表明对发布流程有改进
3. **代码精简**App.axaml.cs 的大幅精简符合代码整洁原则
4. **文档完善**:新增 PLONDS 重设计规格文档,便于理解和维护
### 潜在风险
1. **字体缺失**:删除所有 MiSans 字体文件可能导致应用显示问题,需确保已提供替代方案
2. **测试覆盖减少**:删除了窗口层隔离测试代码,需确认是否有替代测试
3. **工作流变更**PLONDS 工作流的修改需要验证发布流程是否仍然正常工作
4. **兼容性**CheckIpcAot 项目的添加可能引入新的依赖或 AOT 相关问题
### 建议
1. **验证字体替代方案**:确认应用是否已配置使用系统字体或其他字体
2. **测试 PLONDS 工作流**:手动触发一次工作流验证其正常运行
3. **补充测试**:如果删除的测试是重要的,考虑补充替代测试
4. **验证桌面组件功能**:重点测试桌面组件窗口和管理器的变更是否正常工作
5. **检查 AOT 配置**:确保新增的 CheckIpcAot 项目和 Launcher AOT 变更不会影响现有 AOT 构建

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)"
}