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

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

View File

@@ -1,4 +1,4 @@
name: PLONDS Comparator name: PLONDS Comparator
concurrency: 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: '比较方法'
required: false
type: choice
default: file-compare
options:
- file-compare
- commit-analyze
hash_algorithm:
description: '哈希算法(仅 file-compare 模式)'
required: false
type: choice
default: sha256
options:
- sha256
- md5
env: 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,181 +100,153 @@ 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 /tmp/baseline-dl
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null mv /tmp/baseline-dl/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 [[ -n "$BASELINE_TAG" ]]; then
ARGS+=(
'--baseline-version' "$BASELINE_VERSION"
'--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
) )
fi
if ([bool]$entry.isFullPayload) { dotnet "${ARGS[@]}"
$args += @('--is-full-payload', 'true')
}
else {
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
}
dotnet @args - name: Run build-delta-from-commits (commit-analyze)
} if: env.COMPARE_METHOD == 'commit-analyze'
shell: bash
run: |
set -euo pipefail
mkdir -p plonds-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- ` ARGS=(
build-index ` 'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
--release-tag $plan.tag ` '--configuration' 'Release' '--'
--version $plan.version ` 'build-delta-from-commits'
--channel $plan.channel ` '--platform' 'windows-x64'
--platform-summaries-dir plonds-output/platform-summaries ` '--current-version' "$RELEASE_VERSION"
--output-dir plonds-output ` '--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
--private-key $env:UPDATE_PRIVATE_KEY_PATH '--output-dir' "$PWD/plonds-output"
'--channel' "$RELEASE_CHANNEL"
'--baseline-tag' "${BASELINE_TAG:-v0.0.0}"
'--current-tag' "$RELEASE_TAG"
'--hash-algorithm' "$HASH_ALGORITHM"
)
foreach ($entry in $plan.platforms) { if [[ -n "$BASELINE_TAG" ]]; then
$summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json ARGS+=(
$required = @( '--baseline-version' "$BASELINE_VERSION"
"plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json", '--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
"plonds-output/static/meta/distributions/$($summary.distributionId).json",
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
) )
fi
foreach ($path in $required) { dotnet "${ARGS[@]}"
if (-not (Test-Path $path)) {
throw "Missing PLONDS static output: $path"
}
}
}
$objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue - name: Validate output
if (-not $objects -or $objects.Count -eq 0) { shell: bash
throw "PLONDS static object repository is empty." run: |
} set -euo pipefail
if [[ ! -f plonds-output/changed.zip ]]; then
echo "Missing output: changed.zip"
exit 1
fi
if [[ ! -f plonds-output/PLONDS.json ]]; then
echo "Missing output: PLONDS.json"
exit 1
fi
jq -e . plonds-output/PLONDS.json >/dev/null
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force - name: Upload to GitHub Release
- name: Upload PLONDS assets to 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/tag.txt
- name: Upload run metadata artifact - name: Upload run metadata artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -266,11 +255,3 @@ jobs:
path: plonds-run-metadata/tag.txt path: plonds-run-metadata/tag.txt
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7
- name: Upload PLONDS static artifact
uses: actions/upload-artifact@v4
with:
name: plonds-static
path: plonds-output/static/**
if-no-files-found: error
retention-days: 7

View File

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

View File

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

10
CheckIpcAot/Program.cs Normal file
View File

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

View File

@@ -39,4 +39,9 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PrivacyAgreementState))] [JsonSerializable(typeof(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 try
{ {
if (closeLibrary && _fusedComponentLibraryWindow is { } libraryWindow) FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
{
_fusedComponentLibraryWindow = null;
libraryWindow.Closed -= OnFusedComponentLibraryWindowClosed;
libraryWindow.Close();
}
try
{
_transparentOverlayWindow?.SaveLayoutAndHide();
}
catch (Exception overlayEx)
{
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx);
}
try
{
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
catch (Exception exitEx)
{
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx);
}
} }
finally catch (Exception ex)
{ {
_isExitingFusedDesktopEditMode = false; AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode after library closed.", ex);
} }
} }
@@ -890,11 +841,6 @@ public partial class App : Application
{ {
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'."); 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();
@@ -938,26 +884,6 @@ public partial class App : Application
return false; return false;
} }
} }
private void EnsureTransparentOverlayWindow()
{
if (_transparentOverlayWindow is null)
{
_transparentOverlayWindow = new TransparentOverlayWindow();
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
{
RestoreOrCreateMainWindow("TransparentOverlay");
};
_transparentOverlayWindow.ExitEditRequested += (s, e) =>
{
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
};
_transparentOverlayWindow.RestoreComponentLibraryRequested += (s, e) =>
{
OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: true);
};
}
}
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request) internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
{ {
@@ -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 FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
{ }
_transparentOverlayWindow.Close(); catch (Exception ex)
} {
catch (Exception ex) AppLogger.Warn("FusedDesktop", "Failed to shut down fused desktop manager during exit cleanup.", ex);
{
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay 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.

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,46 +12,46 @@ 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();
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private ComponentRegistry? _componentRegistry; private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
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,
ISettingsFacadeService settingsFacade) ISettingsFacadeService settingsFacade)
{ {
_layoutService = layoutService; _layoutService = layoutService;
_settingsFacade = settingsFacade; _settingsFacade = settingsFacade;
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService(); _weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
_timeZoneService = _settingsFacade.Region.GetTimeZoneService(); _timeZoneService = _settingsFacade.Region.GetTimeZoneService();
} }
@@ -58,15 +59,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
public void Initialize() public void Initialize()
{ {
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)
{ {
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization."); AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
return; return;
} }
EnsureRegistries(); EnsureRegistries();
ReloadWidgets(); ReloadWidgets();
} }
@@ -74,7 +74,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
private void EnsureRegistries() private void EnsureRegistries()
{ {
if (_componentRuntimeRegistry is not null) return; if (_componentRuntimeRegistry is not null) return;
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); _componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry( _componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
@@ -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,25 +101,91 @@ 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);
foreach (var placement in layout.ComponentPlacements) foreach (var placement in layout.ComponentPlacements)
{ {
existingIds.Remove(placement.PlacementId); existingIds.Remove(placement.PlacementId);
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();
} }
} }
@@ -148,8 +218,7 @@ 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))
@@ -179,7 +248,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}"); AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
return null; return null;
} }
var control = descriptor.CreateControl( var control = descriptor.CreateControl(
DefaultCellSize, DefaultCellSize,
_timeZoneService, _timeZoneService,
@@ -188,28 +257,24 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
_calculatorDataService, _calculatorDataService,
_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;
private static readonly object _lock = new(); private static readonly object _lock = new();
public static IFusedDesktopManagerService GetOrCreate() public static IFusedDesktopManagerService GetOrCreate()
{ {
if (_instance is not null) return _instance; if (_instance is not null) return _instance;
lock (_lock) lock (_lock)
{ {
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,12 @@
namespace Plonds.Core.Publishing; namespace Plonds.Core.Publishing;
public sealed record PlondsDeltaBuildOptions( 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 = ValidateHashAlgorithmInternal(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,200 @@ 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, CompareMethod: PlondsConstants.CompareMethodFileCompare,
FileMapUrl: fileMapUrl, HashAlgorithm: hashAlgorithm,
FileMapSignatureUrl: fileMapUrl + ".sig", FilesMap: filesMap,
Components: [component], ChangedFilesMap: changedFilesMap,
InstallerMirrors: [], Checksums: new Dictionary<string, string>
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) internal static string ValidateHashAlgorithmInternal(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;
{
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 FileMapDocument( private static Dictionary<string, PlondsFileEntry> BuildFilesMap(
string FormatVersion, IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
string DistributionId, IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string FromVersion, string hashAlgorithm)
string ToVersion, {
string Version, var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
string Platform,
string Arch,
string Channel,
DateTimeOffset GeneratedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<FileEntryDocument> Files);
private sealed record ComponentDocument( foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
string Name, {
string Version, var current = currentManifest[path];
IReadOnlyDictionary<string, string>? Metadata, var currentHash = GetHash(current, hashAlgorithm);
IReadOnlyList<FileEntryDocument> Files);
private sealed record FileEntryDocument( if (previousManifest.TryGetValue(path, out var previous))
string Path, {
string Action, var previousHash = GetHash(previous, hashAlgorithm);
string Sha256, if (string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase))
long Size, {
string? ObjectPath, filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReuse, currentHash, current.Size, hashAlgorithm);
string? ObjectKey, continue;
string? ObjectUrl, }
IReadOnlyDictionary<string, string>? Metadata); }
private sealed record DistributionDocument( var action = previousManifest.ContainsKey(path)
string DistributionId, ? PlondsConstants.ActionReplace
string Version, : PlondsConstants.ActionAdd;
string SourceVersion, filesMap[path] = new PlondsFileEntry(action, currentHash, current.Size, hashAlgorithm);
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( foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
string DistributionId, {
string Version, if (!currentManifest.ContainsKey(path))
string Channel, {
string Platform, filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionDelete, string.Empty, 0, hashAlgorithm);
DateTimeOffset PublishedAt); }
}
private sealed record InstallerMirrorDocument( return filesMap;
string Platform, }
string? Url,
string? FileName, private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
string? Sha256, {
long Size); if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
{
return ComputeMd5Hex(fingerprint.FullPath);
}
return fingerprint.Sha256;
}
private static Dictionary<string, PlondsChangedFileEntry> BuildChangedFilesMap(
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string hashAlgorithm)
{
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var (path, entry) in filesMap)
{
if (entry.Action is PlondsConstants.ActionAdd or PlondsConstants.ActionReplace)
{
changedFilesMap[path] = new PlondsChangedFileEntry(path, entry.Hash, entry.Size, hashAlgorithm);
}
}
return changedFilesMap;
}
private static string CreateChangedZip(
string currentExtractRoot,
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string outputRoot,
string platform)
{
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
foreach (var (path, entry) in filesMap)
{
if (entry.Action is not (PlondsConstants.ActionAdd or PlondsConstants.ActionReplace))
{
continue;
}
var sourcePath = Path.Combine(currentExtractRoot, path);
if (!File.Exists(sourcePath))
{
continue;
}
var destPath = Path.Combine(stagingRoot, path);
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrWhiteSpace(destDir))
{
Directory.CreateDirectory(destDir);
}
File.Copy(sourcePath, destPath, overwrite: true);
}
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
return changedZipPath;
}
private static bool DetectLauncherChange(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string launcherRelativePath)
{
var normalizedPath = launcherRelativePath.Replace('\\', '/');
if (!currentManifest.TryGetValue(normalizedPath, out var current))
{
return false;
}
if (!previousManifest.TryGetValue(normalizedPath, out var previous))
{
return true;
}
return !string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase);
}
private static string ComputeMd5Hex(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,7 @@
namespace Plonds.Shared.Models; 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,19 @@
namespace Plonds.Shared.Models; using System.Text.Json.Serialization;
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,
string CompareMethod,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
string? HashAlgorithm,
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,23 +3,39 @@ namespace Plonds.Shared;
public static class PlondsConstants public 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 CompareMethodFileCompare = "file-compare";
public const string CompressedObjectMode = "compressed-object"; public const string CompareMethodCommitAnalyze = "commit-analyze";
public const string BinaryPatchMode = "binary-patch";
public static readonly string[] SupportedFileModes = public const string HashAlgorithmSha256 = "sha256";
public const string HashAlgorithmMd5 = "md5";
public const string DefaultLauncherRelativePath = "LanMountainDesktop.Launcher.exe";
public static readonly string[] SupportedActions =
[ [
FileObjectMode, ActionAdd,
CompressedObjectMode, ActionReplace,
BinaryPatchMode ActionReuse,
ActionDelete
];
public static readonly string[] SupportedHashAlgorithms =
[
HashAlgorithmSha256,
HashAlgorithmMd5
];
public static readonly string[] SupportedCompareMethods =
[
CompareMethodFileCompare,
CompareMethodCommitAnalyze
]; ];
} }

View File

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

View File

@@ -1,5 +1,4 @@
using Plonds.Core.Publishing; using Plonds.Core.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,51 @@ internal static class PlondsCli
} }
} }
private static void RunGenerate(Dictionary<string, string> options) private static void RunBuildDelta(Dictionary<string, string> options)
{ {
var generator = new PlondsGenerator(); var 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"),
FallbackBaselineZip: Get(options, "fallback-zip"),
BaselineVersion: Get(options, "baseline-version"),
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
Console.WriteLine($"Built PLONDS commit-delta for {result.Platform}:");
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
Console.WriteLine($" FellBackToFileCompare: {result.FellBackToFileCompare}");
Console.WriteLine($" ChangedSourceFiles: {result.ChangedSourceFiles.Count}");
Console.WriteLine($" MappedArtifactFiles: {result.MappedArtifactFiles.Count}");
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
Console.WriteLine($" Manifest: {result.ManifestPath}");
} }
private static void RunPackPayload(Dictionary<string, string> options) private static void RunPackPayload(Dictionary<string, string> options)
@@ -121,56 +96,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 +137,8 @@ internal static class PlondsCli
private static void PrintUsage() private static void PrintUsage()
{ {
Console.WriteLine("PLONDS Tool"); Console.WriteLine("PLONDS Tool");
Console.WriteLine(" build-delta --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> [--channel <ch>] [--baseline-version <v>] [--baseline-zip <file>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
Console.WriteLine(" build-delta-from-commits --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> --channel <ch> --baseline-tag <tag> --current-tag <tag> [--fallback-zip <file>] [--baseline-version <v>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>"); Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload] [--static-output-dir <dir>] [--update-base-url <url>]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-plonds --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
} }
} }

31
check_ipc.cs Normal file
View File

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

71
size_analysis.ps1 Normal file
View File

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

85
size_analysis2.ps1 Normal file
View File

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