diff --git a/.github/workflows/plonds-comparator.yml b/.github/workflows/plonds-comparator.yml index 8d97aa7..45c2bf9 100644 --- a/.github/workflows/plonds-comparator.yml +++ b/.github/workflows/plonds-comparator.yml @@ -1,4 +1,4 @@ -name: PLONDS Comparator +name: PLONDS Comparator concurrency: group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }} @@ -9,7 +9,6 @@ on: types: - published - prereleased - - edited workflow_dispatch: inputs: tag: @@ -17,7 +16,7 @@ on: required: true type: string baseline_tag: - description: 'Optional baseline tag' + description: 'Optional baseline tag (auto-detected if omitted)' required: false type: string channel: @@ -28,12 +27,28 @@ on: options: - stable - preview + compare_method: + description: '比较方法' + required: false + type: choice + default: file-compare + options: + - file-compare + - commit-analyze + hash_algorithm: + description: '哈希算法(仅 file-compare 模式)' + required: false + type: choice + default: sha256 + options: + - sha256 + - md5 env: DOTNET_VERSION: '10.0.x' jobs: - build: + compare: runs-on: ubuntu-latest permissions: contents: write @@ -48,6 +63,7 @@ jobs: - name: Resolve release context shell: bash run: | + set -euo pipefail if [[ "${{ github.event_name }}" == "release" ]]; then TAG="${{ github.event.release.tag_name }}" if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then @@ -55,7 +71,9 @@ jobs: else CHANNEL="stable" fi - BASELINE_TAG="" + BASELINE_TAG_INPUT="" + COMPARE_METHOD="file-compare" + HASH_ALGORITHM="sha256" else RAW_TAG="${{ github.event.inputs.tag }}" if [[ "${RAW_TAG}" == v* ]]; then @@ -64,18 +82,17 @@ jobs: TAG="v${RAW_TAG}" fi CHANNEL="${{ github.event.inputs.channel }}" - BASELINE_TAG="${{ github.event.inputs.baseline_tag }}" + BASELINE_TAG_INPUT="${{ github.event.inputs.baseline_tag }}" + COMPARE_METHOD="${{ github.event.inputs.compare_method }}" + HASH_ALGORITHM="${{ github.event.inputs.hash_algorithm }}" fi echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV" echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" - echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV" - PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}" - if [[ -z "$PUBLIC_BASE" ]]; then - PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update" - fi - echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV" + echo "BASELINE_TAG_INPUT=${BASELINE_TAG_INPUT}" >> "$GITHUB_ENV" + echo "COMPARE_METHOD=${COMPARE_METHOD}" >> "$GITHUB_ENV" + echo "HASH_ALGORITHM=${HASH_ALGORITHM}" >> "$GITHUB_ENV" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -83,181 +100,153 @@ jobs: dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-quality: preview - - name: Prepare signing key - env: - UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} - PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }} - shell: bash - run: | - set -euo pipefail - KEY="${PLONDS_SIGNING_KEY:-}" - if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi - if [[ -z "$KEY" ]]; then - echo "No signing key is configured." - exit 1 - fi - printf '%s' "$KEY" > update-private-key.pem - echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV" - - name: Build PLONDS tool run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release - - name: Resolve baseline plan + - name: Resolve baseline env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: pwsh + shell: bash run: | - $ErrorActionPreference = 'Stop' - $repo = '${{ github.repository }}' - $tag = $env:RELEASE_TAG - $baselineInput = $env:BASELINE_TAG_INPUT - $currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json - $allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json - $platforms = @('windows-x64', 'windows-x86', 'linux-x64') + set -euo pipefail + BASELINE_TAG="" + BASELINE_VERSION="" - $entries = foreach ($platform in $platforms) { - $assetName = "files-$platform.zip" - $currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 - if (-not $currentAsset) { - throw "Current release $tag does not contain required asset $assetName" - } + if [[ -n "$BASELINE_TAG_INPUT" ]]; then + NORMALIZED="$BASELINE_TAG_INPUT" + if [[ "$NORMALIZED" != v* ]]; then NORMALIZED="v$NORMALIZED"; fi + if gh release view "$NORMALIZED" --repo "${{ github.repository }}" --json tagName >/dev/null 2>&1; then + BASELINE_TAG="$NORMALIZED" + BASELINE_VERSION="${NORMALIZED#v}" + else + echo "Specified baseline tag not found: $NORMALIZED" + exit 1 + fi + else + IS_PRERELEASE="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')" + CANDIDATES="$(gh api "repos/${{ github.repository }}/releases?per_page=50" \ + --jq ".[] | select(.draft == false and .prerelease == ${IS_PRERELEASE} and .tag_name != \"${RELEASE_TAG}\") | .tag_name")" - $baselineRelease = $null - if (-not [string]::IsNullOrWhiteSpace($baselineInput)) { - $normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" } - $baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1 - if (-not $baselineRelease) { - throw "Specified baseline tag not found: $normalizedBaseline" - } - } - else { - $baselineRelease = $allReleases | - Where-Object { - $_.tag_name -ne $tag -and - -not $_.draft -and - [bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and - ($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0 - } | - Select-Object -First 1 - } + for CANDIDATE in $CANDIDATES; do + if gh release download "$CANDIDATE" -p "files-windows-x64.zip" -D /tmp/baseline-check --clobber 2>/dev/null; then + BASELINE_TAG="$CANDIDATE" + BASELINE_VERSION="${CANDIDATE#v}" + rm -rf /tmp/baseline-check + break + fi + done + fi - [pscustomobject]@{ - platform = $platform - assetName = $assetName - baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null } - baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null } - isFullPayload = -not $baselineRelease - } - } - - $plan = [pscustomobject]@{ - tag = $tag - version = $env:RELEASE_VERSION - channel = $env:RELEASE_CHANNEL - platforms = $entries - } - - $plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8 - Get-Content plonds-plan.json + if [[ -n "$BASELINE_TAG" ]]; then + echo "BASELINE_TAG=${BASELINE_TAG}" >> "$GITHUB_ENV" + echo "BASELINE_VERSION=${BASELINE_VERSION}" >> "$GITHUB_ENV" + echo "Resolved baseline: ${BASELINE_TAG}" + else + echo "No baseline found. This will be a full update." + fi - name: Download payload zips env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: pwsh + shell: bash run: | - $ErrorActionPreference = 'Stop' - $repo = '${{ github.repository }}' - $plan = Get-Content plonds-plan.json | ConvertFrom-Json + set -euo pipefail + mkdir -p plonds-input - foreach ($entry in $plan.platforms) { - $currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)" - New-Item -ItemType Directory -Path $currentDir -Force | Out-Null - gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir + gh release download "$RELEASE_TAG" -p "files-windows-x64.zip" -D plonds-input + mv plonds-input/files-windows-x64.zip plonds-input/current-files-windows-x64.zip - if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) { - $baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)" - New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null - gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir - } - } + if [[ -n "$BASELINE_TAG" ]]; then + gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D /tmp/baseline-dl + mv /tmp/baseline-dl/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip + fi - - name: Build delta assets - shell: pwsh + - name: Run build-delta (file-compare) + if: env.COMPARE_METHOD == 'file-compare' + shell: bash run: | - $ErrorActionPreference = 'Stop' - $plan = Get-Content plonds-plan.json | ConvertFrom-Json - foreach ($entry in $plan.platforms) { - $currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)" - $args = @( - 'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--', - 'build-delta', - '--platform', $entry.platform, - '--current-version', $plan.version, - '--current-tag', $plan.tag, - '--current-zip', $currentZip, - '--output-dir', 'plonds-output', - '--private-key', $env:UPDATE_PRIVATE_KEY_PATH, - '--channel', $plan.channel, - '--static-output-dir', 'plonds-output/static', - '--update-base-url', $env:S3_PUBLIC_BASE_URL + set -euo pipefail + mkdir -p plonds-output + + ARGS=( + 'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj' + '--configuration' 'Release' '--' + 'build-delta' + '--platform' 'windows-x64' + '--current-version' "$RELEASE_VERSION" + '--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip" + '--output-dir' "$PWD/plonds-output" + '--channel' "$RELEASE_CHANNEL" + '--hash-algorithm' "$HASH_ALGORITHM" + ) + + if [[ -n "$BASELINE_TAG" ]]; then + ARGS+=( + '--baseline-version' "$BASELINE_VERSION" + '--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip" ) + fi - if ([bool]$entry.isFullPayload) { - $args += @('--is-full-payload', 'true') - } - else { - $baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)" - $args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip) - } + dotnet "${ARGS[@]}" - dotnet @args - } + - name: Run build-delta-from-commits (commit-analyze) + if: env.COMPARE_METHOD == 'commit-analyze' + shell: bash + run: | + set -euo pipefail + mkdir -p plonds-output - dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- ` - build-index ` - --release-tag $plan.tag ` - --version $plan.version ` - --channel $plan.channel ` - --platform-summaries-dir plonds-output/platform-summaries ` - --output-dir plonds-output ` - --private-key $env:UPDATE_PRIVATE_KEY_PATH + ARGS=( + 'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj' + '--configuration' 'Release' '--' + 'build-delta-from-commits' + '--platform' 'windows-x64' + '--current-version' "$RELEASE_VERSION" + '--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip" + '--output-dir' "$PWD/plonds-output" + '--channel' "$RELEASE_CHANNEL" + '--baseline-tag' "${BASELINE_TAG:-v0.0.0}" + '--current-tag' "$RELEASE_TAG" + '--hash-algorithm' "$HASH_ALGORITHM" + ) - foreach ($entry in $plan.platforms) { - $summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json - $required = @( - "plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json", - "plonds-output/static/meta/distributions/$($summary.distributionId).json", - "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json", - "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig" + if [[ -n "$BASELINE_TAG" ]]; then + ARGS+=( + '--baseline-version' "$BASELINE_VERSION" + '--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip" ) + fi - foreach ($path in $required) { - if (-not (Test-Path $path)) { - throw "Missing PLONDS static output: $path" - } - } - } + dotnet "${ARGS[@]}" - $objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue - if (-not $objects -or $objects.Count -eq 0) { - throw "PLONDS static object repository is empty." - } + - name: Validate output + shell: bash + run: | + set -euo pipefail + if [[ ! -f plonds-output/changed.zip ]]; then + echo "Missing output: changed.zip" + exit 1 + fi + if [[ ! -f plonds-output/PLONDS.json ]]; then + echo "Missing output: PLONDS.json" + exit 1 + fi + jq -e . plonds-output/PLONDS.json >/dev/null - Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force - - - name: Upload PLONDS assets to release + - name: Upload to GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail - gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber + gh release upload "$RELEASE_TAG" plonds-output/changed.zip plonds-output/PLONDS.json --clobber - name: Persist run metadata shell: bash run: | mkdir -p plonds-run-metadata printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt + printf '%s' "$COMPARE_METHOD" >> plonds-run-metadata/tag.txt - name: Upload run metadata artifact uses: actions/upload-artifact@v4 @@ -266,11 +255,3 @@ jobs: path: plonds-run-metadata/tag.txt if-no-files-found: error retention-days: 7 - - - name: Upload PLONDS static artifact - uses: actions/upload-artifact@v4 - with: - name: plonds-static - path: plonds-output/static/** - if-no-files-found: error - retention-days: 7 diff --git a/.trae/specs/plonds-comparator-redesign/spec.md b/.trae/specs/plonds-comparator-redesign/spec.md new file mode 100644 index 0000000..0885e09 --- /dev/null +++ b/.trae/specs/plonds-comparator-redesign/spec.md @@ -0,0 +1,512 @@ +# PLONDS Comparator 改造设计 + +> 日期:2026-05-30 +> 状态:待审批 + +## 1. 背景与动机 + +PLONDS(Penguin 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 + --current-version + --current-zip + --output-dir + --channel + [--baseline-version ] + [--baseline-zip ] + [--launcher-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 FilesMap, + IReadOnlyDictionary ChangedFilesMap, + IReadOnlyDictionary 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` 而非 `IReadOnlyList`,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. 对比哈希值,生成 filesMap(add/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 + --current-version + --current-zip + --output-dir + --channel + --baseline-tag + --current-tag + [--source-dirs ] + [--fallback-zip ] +``` + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--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 命令的清理(后续处理) diff --git a/CheckIpcAot/CheckIpcAot.csproj b/CheckIpcAot/CheckIpcAot.csproj new file mode 100644 index 0000000..7e57883 --- /dev/null +++ b/CheckIpcAot/CheckIpcAot.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/CheckIpcAot/Program.cs b/CheckIpcAot/Program.cs new file mode 100644 index 0000000..2b7b2c5 --- /dev/null +++ b/CheckIpcAot/Program.cs @@ -0,0 +1,10 @@ +using dotnetCampus.Ipc.CompilerServices.Attributes; +using System.Threading.Tasks; + +[IpcPublic] +public interface IMyService { + Task DoWork(MyRequest req); +} + +public class MyResult { public string Msg {get;set;} } +public class MyRequest { public string Data {get;set;} } diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index f3a1530..89f9356 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -39,4 +39,9 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(PrivacyAgreementState))] [JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))] [JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))] +[JsonSerializable(typeof(AirAppOpenRequest))] +[JsonSerializable(typeof(AirAppRegistrationRequest))] +[JsonSerializable(typeof(AirAppInstanceInfo))] +[JsonSerializable(typeof(AirAppOperationResult))] +[JsonSerializable(typeof(AirAppInstanceInfo[]))] internal sealed partial class AppJsonContext : JsonSerializerContext; diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props index e002f31..d94aafd 100644 --- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props +++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props @@ -52,6 +52,7 @@ + false @@ -60,7 +61,8 @@ - false + + true true diff --git a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs index e5927c2..85734a0 100644 --- a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs +++ b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs @@ -146,15 +146,10 @@ public sealed class WindowLayerIsolationTests public void FusedDesktopWindows_KeepDesktopBottomMostBoundary() { var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs"); - var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs"); Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow); Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow); Assert.Contains("SendToBottom", desktopWidgetWindow); - - Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow); - Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow); - Assert.Contains("SendToBottom", transparentOverlayWindow); } [Fact] diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 57b592a..b65e87d 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -75,9 +75,7 @@ public partial class App : Application private DispatcherTimer? _shellRecoveryTimer; private PluginRuntimeService? _pluginRuntimeService; private MainWindow? _mainWindow; - private TransparentOverlayWindow? _transparentOverlayWindow; private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow; - private bool _isExitingFusedDesktopEditMode; private bool _mainWindowClosed; private DesktopShellHost? _desktopShellHost; private PublicIpcHostService? _publicIpcHostService; @@ -454,22 +452,10 @@ public partial class App : Application try { - var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate(); - fusedDesktopManager.EnterEditMode(); - - EnsureTransparentOverlayWindow(); - if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible) - { - _transparentOverlayWindow.Show(); - } + FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode(); if (_fusedComponentLibraryWindow is { } existingWindow) { - if (_transparentOverlayWindow is not null) - { - existingWindow.SetOverlayWindow(_transparentOverlayWindow); - } - if (!existingWindow.IsVisible) { existingWindow.Show(); @@ -477,7 +463,7 @@ public partial class App : Application if (centerInWorkArea) { - existingWindow.CenterInWorkArea(_transparentOverlayWindow); + existingWindow.CenterInWorkArea(); } existingWindow.Activate(); @@ -486,16 +472,12 @@ public partial class App : Application var window = new FusedDesktopComponentLibraryWindow(); _fusedComponentLibraryWindow = window; - if (_transparentOverlayWindow is not null) - { - window.SetOverlayWindow(_transparentOverlayWindow); - } window.Closed += OnFusedComponentLibraryWindowClosed; window.Show(); if (centerInWorkArea) { - window.CenterInWorkArea(_transparentOverlayWindow); + window.CenterInWorkArea(); } window.Activate(); @@ -503,7 +485,13 @@ public partial class App : Application catch (Exception ex) { AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex); - ExitFusedDesktopEditModeFromUi(closeLibrary: true); + FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); + if (_fusedComponentLibraryWindow is { } libWindow) + { + _fusedComponentLibraryWindow = null; + libWindow.Closed -= OnFusedComponentLibraryWindowClosed; + libWindow.Close(); + } } } @@ -520,50 +508,13 @@ public partial class App : Application _fusedComponentLibraryWindow = null; } - if (!window.PreserveEditModeOnClose && !_isExitingFusedDesktopEditMode) - { - ExitFusedDesktopEditModeFromUi(closeLibrary: false); - } - } - - private void ExitFusedDesktopEditModeFromUi(bool closeLibrary) - { - if (_isExitingFusedDesktopEditMode) - { - return; - } - - _isExitingFusedDesktopEditMode = true; try { - if (closeLibrary && _fusedComponentLibraryWindow is { } libraryWindow) - { - _fusedComponentLibraryWindow = null; - libraryWindow.Closed -= OnFusedComponentLibraryWindowClosed; - libraryWindow.Close(); - } - - try - { - _transparentOverlayWindow?.SaveLayoutAndHide(); - } - catch (Exception overlayEx) - { - AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx); - } - - try - { - FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); - } - catch (Exception exitEx) - { - AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx); - } + FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); } - finally + catch (Exception ex) { - _isExitingFusedDesktopEditMode = false; + AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode after library closed.", ex); } } @@ -890,11 +841,6 @@ public partial class App : Application { AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'."); - if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible) - { - _transparentOverlayWindow.Hide(); - } - var mainWindow = GetOrCreateMainWindow(desktop, source); mainWindow.PrepareEnterAnimation(); @@ -938,26 +884,6 @@ public partial class App : Application return false; } } - - private void EnsureTransparentOverlayWindow() - { - if (_transparentOverlayWindow is null) - { - _transparentOverlayWindow = new TransparentOverlayWindow(); - _transparentOverlayWindow.RestoreMainWindowRequested += (s, e) => - { - RestoreOrCreateMainWindow("TransparentOverlay"); - }; - _transparentOverlayWindow.ExitEditRequested += (s, e) => - { - ExitFusedDesktopEditModeFromUi(closeLibrary: true); - }; - _transparentOverlayWindow.RestoreComponentLibraryRequested += (s, e) => - { - OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: true); - }; - } - } internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request) { @@ -1263,31 +1189,16 @@ public partial class App : Application finally { _fusedComponentLibraryWindow = null; - try - { - FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); - } - catch (Exception ex) - { - AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex); - } } } - if (_transparentOverlayWindow is not null) + try { - try - { - _transparentOverlayWindow.Close(); - } - catch (Exception ex) - { - AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex); - } - finally - { - _transparentOverlayWindow = null; - } + FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktop", "Failed to shut down fused desktop manager during exit cleanup.", ex); } AudioRecorderServiceFactory.DisposeSharedServices(); @@ -1572,13 +1483,6 @@ public partial class App : Application AppLogger.Info( "DesktopShell", $"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'."); - - var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop) - { - EnsureTransparentOverlayWindow(); - _transparentOverlayWindow?.Show(); - } } catch (Exception ex) { @@ -1668,7 +1572,6 @@ public partial class App : Application if (IsMainWindowDesktopLayerEnabled()) { - ExitFusedDesktopEditModeFromUi(closeLibrary: true); FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown(); _mainWindow.ShowInTaskbar = false; _mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow); @@ -1697,7 +1600,6 @@ public partial class App : Application return; } - ExitFusedDesktopEditModeFromUi(closeLibrary: true); FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown(); } catch (Exception ex) diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Bold.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Bold.otf deleted file mode 100644 index 74bde04..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Bold.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Demibold.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Demibold.otf deleted file mode 100644 index 7c8fbfa..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Demibold.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-ExtraLight.otf b/LanMountainDesktop/Assets/Fonts/MiSans-ExtraLight.otf deleted file mode 100644 index 93c01dd..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-ExtraLight.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Heavy.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Heavy.otf deleted file mode 100644 index 5c5152d..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Heavy.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Light.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Light.otf deleted file mode 100644 index 05018c7..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Light.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Medium.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Medium.otf deleted file mode 100644 index cffb77f..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Medium.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md b/LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md deleted file mode 100644 index d571848..0000000 --- a/LanMountainDesktop/Assets/Fonts/MiSans-NOTICE.md +++ /dev/null @@ -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. diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Normal.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Normal.otf deleted file mode 100644 index 7d53bcf..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Normal.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Regular.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Regular.otf deleted file mode 100644 index 4ce1a99..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Regular.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Semibold.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Semibold.otf deleted file mode 100644 index 77b835a..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Semibold.otf and /dev/null differ diff --git a/LanMountainDesktop/Assets/Fonts/MiSans-Thin.otf b/LanMountainDesktop/Assets/Fonts/MiSans-Thin.otf deleted file mode 100644 index 3f0673a..0000000 Binary files a/LanMountainDesktop/Assets/Fonts/MiSans-Thin.otf and /dev/null differ diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs index 8177e32..0dac720 100644 --- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs +++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; @@ -11,46 +12,46 @@ using LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Services; -/// -/// 融合桌面中央管理器服务接口 -/// public interface IFusedDesktopManagerService { void Initialize(); - void EnterEditMode(); - void ExitEditMode(); void ReloadWidgets(); void Shutdown(); + void AddComponent(string componentId); + void RemoveComponent(string placementId); + void EnterEditMode(); + void ExitEditMode(); + bool IsEditMode { get; } } -/// -/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。 -/// internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService { private readonly IFusedDesktopLayoutService _layoutService; private readonly ISettingsFacadeService _settingsFacade; private readonly Dictionary _widgetWindows = []; - - // 基础服务依赖 + private readonly IWeatherInfoService _weatherDataService; private readonly TimeZoneService _timeZoneService; private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); - + private ComponentRegistry? _componentRegistry; private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; private bool _isEditMode; private const double DefaultCellSize = 100; + private const double DefaultComponentWidth = 200; + private const double DefaultComponentHeight = 200; + + public bool IsEditMode => _isEditMode; public FusedDesktopManagerService( - IFusedDesktopLayoutService layoutService, + IFusedDesktopLayoutService layoutService, ISettingsFacadeService settingsFacade) { _layoutService = layoutService; _settingsFacade = settingsFacade; - + _weatherDataService = _settingsFacade.Weather.GetWeatherInfoService(); _timeZoneService = _settingsFacade.Region.GetTimeZoneService(); } @@ -58,15 +59,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService public void Initialize() { if (!OperatingSystem.IsWindows()) return; - - // 检查融合桌面功能是否启用 + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); if (!appSnapshot.EnableFusedDesktop) { AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization."); return; } - + EnsureRegistries(); ReloadWidgets(); } @@ -74,7 +74,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService private void EnsureRegistries() { if (_componentRuntimeRegistry is not null) return; - + var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; _componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); _componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry( @@ -88,12 +88,12 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService if (_isEditMode) return; _isEditMode = true; - // 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层 - // 这样可以保持组件的运行状态(动画、输入等) foreach (var window in _widgetWindows.Values) { - window.Hide(); + window.SetEditMode(true); } + + AppLogger.Info("FusedDesktop", "Entered edit mode."); } public void ExitEditMode() @@ -101,25 +101,91 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService if (!_isEditMode) return; _isEditMode = false; - // 编辑完成,重新加载布局(可能已发生更改)并显示 - ReloadWidgets(); + foreach (var window in _widgetWindows.Values) + { + window.SetEditMode(false); + } + + AppLogger.Info("FusedDesktop", "Exited edit mode."); + } + + public void AddComponent(string componentId) + { + EnsureRegistries(); + if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)) + { + AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {componentId}"); + return; + } + + var placement = new FusedDesktopComponentPlacementSnapshot + { + PlacementId = Guid.NewGuid().ToString("N"), + ComponentId = componentId, + Width = DefaultComponentWidth, + Height = DefaultComponentHeight + }; + + var screen = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime) + ?.MainWindow?.Screens.Primary; + if (screen is not null) + { + var scaling = screen.Scaling; + var workArea = screen.WorkingArea; + placement.X = (workArea.Width / scaling - placement.Width) / 2; + placement.Y = (workArea.Height / scaling - placement.Height) / 2; + } + + _layoutService.AddComponentPlacement(placement); + + try + { + var window = CreateWidgetWindow(placement); + if (window != null) + { + _widgetWindows[placement.PlacementId] = window; + if (_isEditMode) + { + window.SetEditMode(true); + } + + window.Show(); + window.Position = new PixelPoint((int)placement.X, (int)placement.Y); + window.RefreshDesktopLayer(); + } + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktopMgr", $"Failed to create widget window for {componentId}", ex); + _layoutService.RemoveComponentPlacement(placement.PlacementId); + } + + AppLogger.Info("FusedDesktopMgr", $"Added component '{componentId}' with placement '{placement.PlacementId}'."); + } + + public void RemoveComponent(string placementId) + { + if (_widgetWindows.Remove(placementId, out var windowToRemove)) + { + windowToRemove.Close(); + } + + _layoutService.RemoveComponentPlacement(placementId); + AppLogger.Info("FusedDesktopMgr", $"Removed component placement '{placementId}'."); } public void ReloadWidgets() { - if (_isEditMode) return; // 编辑模式下不渲染小窗口 - var layout = _layoutService.Load(); var existingIds = new HashSet(_widgetWindows.Keys); - + foreach (var placement in layout.ComponentPlacements) { existingIds.Remove(placement.PlacementId); - + if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow)) { - // 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。 - existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y); + existingWindow.Position = new PixelPoint((int)placement.X, (int)placement.Y); existingWindow.UpdateComponentLayout(placement.Width, placement.Height); if (existingWindow.IsVisible == false) { @@ -130,15 +196,19 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService } else { - // 新组件,生成窗口 try { var window = CreateWidgetWindow(placement); if (window != null) { _widgetWindows[placement.PlacementId] = window; + if (_isEditMode) + { + window.SetEditMode(true); + } + window.Show(); - window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y); + window.Position = new PixelPoint((int)placement.X, (int)placement.Y); window.RefreshDesktopLayer(); } } @@ -148,8 +218,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService } } } - - // 移除被删除的组件 + foreach (var id in existingIds) { if (_widgetWindows.Remove(id, out var windowToRemove)) @@ -179,7 +248,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}"); return null; } - + var control = descriptor.CreateControl( DefaultCellSize, _timeZoneService, @@ -188,28 +257,24 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService _calculatorDataService, _settingsFacade, placement.PlacementId); - - // 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度) + control.Width = placement.Width; control.Height = placement.Height; - var window = new DesktopWidgetWindow(control); + var window = new DesktopWidgetWindow(control, placement.PlacementId); return window; } } -/// -/// 工厂 -/// public static class FusedDesktopManagerServiceFactory { private static IFusedDesktopManagerService? _instance; private static readonly object _lock = new(); - + public static IFusedDesktopManagerService GetOrCreate() { if (_instance is not null) return _instance; - + lock (_lock) { var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); diff --git a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs index bfcf875..4eaf451 100644 --- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs +++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Threading; using LanMountainDesktop.Services; @@ -12,6 +13,13 @@ public partial class DesktopWidgetWindow : Window private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate(); + private bool _isEditMode; + private bool _isDragging; + private PixelPoint _dragStartWindowPosition; + private Point _dragStartPointerPosition; + + public string? PlacementId { get; } + public DesktopWidgetWindow() { InitializeComponent(); @@ -23,11 +31,34 @@ public partial class DesktopWidgetWindow : Window } } - public DesktopWidgetWindow(Control componentContent) : this() + public DesktopWidgetWindow(Control componentContent, string? placementId = null) : this() { + PlacementId = placementId; ComponentContainer.Child = componentContent; } + public void SetEditMode(bool editMode) + { + if (_isEditMode == editMode) return; + _isEditMode = editMode; + + if (ComponentContainer.Child is Control child) + { + child.IsHitTestVisible = !editMode; + } + + if (editMode) + { + Cursor = new Cursor(StandardCursorType.SizeAll); + } + else + { + Cursor = null; + } + + AppLogger.Info("DesktopWidgetWindow", $"Edit mode set to {editMode}. PlacementId='{PlacementId}'."); + } + public void UpdateComponentLayout(double width, double height) { ComponentContainer.Width = width; @@ -74,6 +105,109 @@ public partial class DesktopWidgetWindow : Window } } + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (_isEditMode && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + BeginDrag(e); + e.Handled = true; + return; + } + + if (!_isEditMode && e.GetCurrentPoint(this).Properties.IsRightButtonPressed) + { + ShowContextMenu(e); + e.Handled = true; + return; + } + + base.OnPointerPressed(e); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + if (_isDragging) + { + var currentPointer = e.GetPosition(this); + var delta = currentPointer - _dragStartPointerPosition; + + Position = new PixelPoint( + _dragStartWindowPosition.X + (int)delta.X, + _dragStartWindowPosition.Y + (int)delta.Y); + + e.Handled = true; + return; + } + + base.OnPointerMoved(e); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + if (_isDragging) + { + EndDrag(); + e.Handled = true; + return; + } + + base.OnPointerReleased(e); + } + + private void BeginDrag(PointerPressedEventArgs e) + { + _isDragging = true; + _dragStartWindowPosition = Position; + _dragStartPointerPosition = e.GetPosition(this); + e.Pointer.Capture(this); + } + + private void EndDrag() + { + _isDragging = false; + + if (PlacementId is not null) + { + var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); + var layout = layoutService.Load(); + var placement = layout.ComponentPlacements.Find( + p => string.Equals(p.PlacementId, PlacementId, StringComparison.OrdinalIgnoreCase)); + if (placement is not null) + { + placement.X = Position.X; + placement.Y = Position.Y; + layoutService.Save(layout); + } + } + + RefreshDesktopLayer(); + } + + private void ShowContextMenu(PointerPressedEventArgs e) + { + var removeItem = new MenuItem + { + Header = "移除组件" + }; + removeItem.Click += (_, _) => + { + if (PlacementId is not null) + { + FusedDesktopManagerServiceFactory.GetOrCreate().RemoveComponent(PlacementId); + } + else + { + Close(); + } + }; + + var menu = new ContextMenu + { + Items = { removeItem } + }; + menu.Open(this); + } + private void UpdateInteractiveRegion() { _regionPassthroughService.SetInteractiveRegions(this, new List diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs index 64ca2c3..12bf473 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs @@ -12,8 +12,6 @@ namespace LanMountainDesktop.Views; public partial class FusedDesktopComponentLibraryWindow : Window { - private TransparentOverlayWindow? _overlayWindow; - public FusedDesktopComponentLibraryWindow() { InitializeComponent(); @@ -45,13 +43,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component; } - public bool PreserveEditModeOnClose { get; private set; } - - public void SetOverlayWindow(TransparentOverlayWindow overlayWindow) - { - _overlayWindow = overlayWindow; - } - public void CenterInWorkArea(Window? referenceWindow = null) { var screen = referenceWindow is not null @@ -74,22 +65,13 @@ public partial class FusedDesktopComponentLibraryWindow : Window private void OnAddComponentRequested(object? sender, string componentId) { - if (_overlayWindow is null) - { - AppLogger.Warn("FusedDesktopLibrary", "Overlay window is not set."); - return; - } - - _overlayWindow.AddComponentToCenter(componentId); - AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' at fused desktop grid center."); - - PreserveEditModeOnClose = true; + FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId); + AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' directly to fused desktop."); Close(); } private void OnCloseClick(object? sender, RoutedEventArgs e) { - PreserveEditModeOnClose = false; Close(); } @@ -105,7 +87,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window { if (e.Key == Key.Escape) { - PreserveEditModeOnClose = false; Close(); } } diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml deleted file mode 100644 index ba4caff..0000000 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs deleted file mode 100644 index 3cb56fd..0000000 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs +++ /dev/null @@ -1,1169 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; -using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Threading; -using LanMountainDesktop.ComponentSystem; -using LanMountainDesktop.DesktopEditing; -using LanMountainDesktop.Models; -using LanMountainDesktop.PluginSdk; -using LanMountainDesktop.Services; -using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.Views.Components; - -namespace LanMountainDesktop.Views; - -public partial class TransparentOverlayWindow : Window -{ - private const double DefaultCellSize = 100; - private const string ResizeHandleTag = "fused-desktop-resize-handle"; - - private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); - private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); - private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate(); - private readonly ISettingsFacadeService _settingsFacade; - private readonly FusedDesktopEditGridAdapter _gridAdapter; - - private readonly IWeatherInfoService _weatherDataService; - private readonly TimeZoneService _timeZoneService; - private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); - private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); - - private readonly Dictionary _componentHosts = []; - private readonly List _interactiveRegions = []; - private FusedDesktopLayoutSnapshot _layout = new(); - private ComponentRegistry? _componentRegistry; - private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; - private FusedDesktopEditGridContext _gridContext; - private double _currentDesktopCellSize = DefaultCellSize; - - private DesktopEditSession _editSession; - private Border? _interactionHost; - private string? _interactionPlacementId; - private Rect _interactionOriginalRect; - private int _interactionStartRow; - private int _interactionStartColumn; - private int _interactionStartWidthCells; - private int _interactionStartHeightCells; - private int _interactionMinWidthCells; - private int _interactionMinHeightCells; - private int _interactionMaxWidthCells; - private int _interactionMaxHeightCells; - private DesktopComponentResizeMode _interactionResizeMode = DesktopComponentResizeMode.Proportional; - - private Border? _selectedHost; - - private bool _isSwipeActive; - private bool _isSwipeDirectionLocked; - private Point _swipeStartPoint; - private Point _swipeCurrentPoint; - private Point _swipeLastPoint; - private double _swipeVelocityX; - private long _swipeLastTimestamp; - private int? _swipePointerId; - private bool _isThreeFingerOrRightDragSwipeActive; - private readonly HashSet _activePointerIds = []; - - public event EventHandler? RestoreMainWindowRequested; - public event EventHandler? ExitEditRequested; - public event EventHandler? RestoreComponentLibraryRequested; - - public TransparentOverlayWindow() - { - InitializeComponent(); - - var facade = HostSettingsFacadeProvider.GetOrCreate(); - _settingsFacade = facade; - _gridAdapter = new FusedDesktopEditGridAdapter(_settingsFacade); - _weatherDataService = facade.Weather.GetWeatherInfoService(); - _timeZoneService = facade.Region.GetTimeZoneService(); - - SizeChanged += OnOverlaySizeChanged; - - if (OperatingSystem.IsWindows()) - { - _bottomMostService.SetupBottomMost(this); - } - } - - public void SaveLayoutAndHide() - { - SaveLayout(); - _regionPassthroughService.ClearInteractiveRegions(this); - Hide(); - ComponentCanvas.Children.Clear(); - _componentHosts.Clear(); - _selectedHost = null; - _editSession = default; - } - - public void AddComponentToCenter(string componentId) - { - AddComponent(componentId, double.NaN, double.NaN); - } - - public void AddComponent(string componentId, double x, double y, double? width = null, double? height = null) - { - EnsureRegistries(); - - if (_componentRuntimeRegistry is null || - !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)) - { - AppLogger.Warn("TransparentOverlay", $"Cannot add unknown component: {componentId}"); - return; - } - - EnsureGridContext(); - var (widthCells, heightCells) = ResolveRequestedSpan(descriptor.Definition, width, height); - var (column, row) = ResolveRequestedCell(x, y, widthCells, heightCells); - var placement = new FusedDesktopComponentPlacementSnapshot - { - PlacementId = Guid.NewGuid().ToString("N"), - ComponentId = componentId, - GridColumn = column, - GridRow = row, - GridWidthCells = widthCells, - GridHeightCells = heightCells, - ZIndex = _layout.ComponentPlacements.Count - }; - ApplyGridPlacementToPixelPlacement(placement); - - _layout.ComponentPlacements.Add(placement); - try - { - RenderComponentInternal(placement); - UpdateInteractiveRegions(); - SaveLayout(); - AppLogger.Info( - "TransparentOverlay", - $"Added component: {componentId} at cell ({column}, {row}) span ({widthCells}x{heightCells})"); - } - catch (Exception ex) - { - AppLogger.Warn("TransparentOverlay", $"Failed to add component {componentId}", ex); - _layout.ComponentPlacements.Remove(placement); - } - } - - public void RemoveComponent(string placementId) - { - if (_componentHosts.Remove(placementId, out var host)) - { - ComponentCanvas.Children.Remove(host); - } - - _layout.ComponentPlacements.RemoveAll(p => string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); - UpdateInteractiveRegions(); - SaveLayout(); - } - - public void RenderComponent(string placementId, Control component, double x, double y, double width, double height) - { - if (_componentHosts.Remove(placementId, out var existingHost)) - { - ComponentCanvas.Children.Remove(existingHost); - } - - component.Width = width; - component.Height = height; - - var contentGrid = new Grid(); - contentGrid.Children.Add(component); - - var resizeHandle = new Border - { - Width = 22, - Height = 22, - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Bottom, - Margin = new Thickness(0, 0, -11, -11), - Cursor = new Cursor(StandardCursorType.BottomRightCorner), - Tag = ResizeHandleTag, - IsVisible = false, - IsHitTestVisible = false, - Classes = { "fused-desktop-resize-handle" } - }; - contentGrid.Children.Add(resizeHandle); - - var host = new Border - { - Tag = placementId, - Width = width, - Height = height, - ClipToBounds = false, - Child = contentGrid, - Classes = { "fused-desktop-component-host" } - }; - - Canvas.SetLeft(host, x); - Canvas.SetTop(host, y); - - host.PointerPressed += OnComponentPointerPressed; - host.PointerMoved += OnInteractionPointerMoved; - host.PointerReleased += OnInteractionPointerReleased; - host.PointerCaptureLost += OnInteractionPointerCaptureLost; - host.ContextRequested += OnComponentContextRequested; - - ComponentCanvas.Children.Add(host); - _componentHosts[placementId] = host; - } - - protected override void OnOpened(EventArgs e) - { - base.OnOpened(e); - - ApplyWorkAreaBounds(); - EnsureGridContext(); - EnsureRegistries(); - - _layout = _layoutService.Load(); - RenderAllComponents(); - - AppLogger.Info( - "TransparentOverlay", - $"Opened with {_layout.ComponentPlacements.Count} components. WindowRole=DesktopSurface."); - - RefreshDesktopLayer(); - - Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background); - DispatcherTimer.RunOnce(LogTransparencyDiagnostics, TimeSpan.FromMilliseconds(250)); - } - - public void RefreshDesktopLayer() - { - if (!OperatingSystem.IsWindows() || !IsVisible) - { - return; - } - - _bottomMostService.SendToBottom(this); - AppLogger.Info("TransparentOverlay", "Refreshed desktop layer. WindowRole=DesktopSurface."); - } - - protected override void OnClosed(EventArgs e) - { - SaveLayout(); - base.OnClosed(e); - } - - private void OnOverlaySizeChanged(object? sender, SizeChangedEventArgs e) - { - if (!IsVisible) - { - return; - } - - EnsureGridContext(); - RenderAllComponents(saveIfMigrated: false); - Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background); - } - - private void ApplyWorkAreaBounds() - { - if (Screens.Primary is not { } primaryScreen) - { - return; - } - - var workArea = primaryScreen.WorkingArea; - var scaling = primaryScreen.Scaling; - Position = new PixelPoint(workArea.X, workArea.Y); - Width = workArea.Width / scaling; - Height = workArea.Height / scaling; - } - - private void LogTransparencyDiagnostics() - { - var actualTransparency = ActualTransparencyLevel; - if (actualTransparency == WindowTransparencyLevel.Transparent) - { - AppLogger.Info( - "TransparentOverlay", - $"ActualTransparencyLevel={actualTransparency}; overlay should be visually transparent."); - return; - } - - AppLogger.Warn( - "TransparentOverlay", - $"ActualTransparencyLevel={actualTransparency}; expected Transparent. The platform, window styles, or desktop host attachment may be preventing true transparency."); - } - - private void EnsureGridContext() - { - var viewport = new Size(Math.Max(1, Width), Math.Max(1, Height)); - if (_gridAdapter.TryCreate(viewport, out var context)) - { - _gridContext = context; - _currentDesktopCellSize = context.Geometry.CellSize; - return; - } - - _gridContext = new FusedDesktopEditGridContext( - new DesktopGridGeometry(default, DefaultCellSize, 0, 1, 1), - new DesktopGridMetrics(1, 1, DefaultCellSize, 0, 0, DefaultCellSize, DefaultCellSize)); - _currentDesktopCellSize = DefaultCellSize; - } - - private void EnsureRegistries() - { - if (_componentRuntimeRegistry is not null) - { - return; - } - - var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; - _componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); - _componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry( - _componentRegistry, - pluginRuntimeService, - _settingsFacade); - } - - private void RenderAllComponents(bool saveIfMigrated = true) - { - ComponentCanvas.Children.Clear(); - _componentHosts.Clear(); - _selectedHost = null; - - var migrated = false; - foreach (var placement in _layout.ComponentPlacements) - { - try - { - migrated |= EnsurePlacementGridFields(placement); - RenderComponentInternal(placement); - } - catch (Exception ex) - { - AppLogger.Warn("TransparentOverlay", $"Failed to render component {placement.ComponentId}", ex); - } - } - - if (migrated && saveIfMigrated) - { - SaveLayout(); - } - - UpdateInteractiveRegions(); - } - - private void RenderComponentInternal(FusedDesktopComponentPlacementSnapshot placement) - { - if (_componentRuntimeRegistry is null || - !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) - { - AppLogger.Warn("TransparentOverlay", $"Unknown component: {placement.ComponentId}"); - return; - } - - EnsurePlacementGridFields(placement); - ApplyGridPlacementToPixelPlacement(placement); - - var control = descriptor.CreateControl( - _currentDesktopCellSize, - _timeZoneService, - _weatherDataService, - _recommendationInfoService, - _calculatorDataService, - _settingsFacade, - placement.PlacementId); - - RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height); - } - - private bool EnsurePlacementGridFields(FusedDesktopComponentPlacementSnapshot placement) - { - if (_componentRuntimeRegistry is null || - !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) - { - return false; - } - - var grid = _gridContext.Geometry; - var oldRow = placement.GridRow; - var oldColumn = placement.GridColumn; - var oldWidthCells = placement.GridWidthCells; - var oldHeightCells = placement.GridHeightCells; - - var widthCells = placement.GridWidthCells ?? PixelSizeToCellSpan(placement.Width); - var heightCells = placement.GridHeightCells ?? PixelSizeToCellSpan(placement.Height); - (widthCells, heightCells) = ComponentPlacementRules.EnsureMinimumSize( - descriptor.Definition, - widthCells, - heightCells); - widthCells = Math.Clamp(widthCells, 1, Math.Max(1, grid.ColumnCount)); - heightCells = Math.Clamp(heightCells, 1, Math.Max(1, grid.RowCount)); - - var column = placement.GridColumn ?? PixelPositionToCell(placement.X, grid.Origin.X); - var row = placement.GridRow ?? PixelPositionToCell(placement.Y, grid.Origin.Y); - column = Math.Clamp(column, 0, Math.Max(0, grid.ColumnCount - widthCells)); - row = Math.Clamp(row, 0, Math.Max(0, grid.RowCount - heightCells)); - - placement.GridColumn = column; - placement.GridRow = row; - placement.GridWidthCells = widthCells; - placement.GridHeightCells = heightCells; - ApplyGridPlacementToPixelPlacement(placement); - - return oldRow != placement.GridRow || - oldColumn != placement.GridColumn || - oldWidthCells != placement.GridWidthCells || - oldHeightCells != placement.GridHeightCells; - } - - private void ApplyGridPlacementToPixelPlacement(FusedDesktopComponentPlacementSnapshot placement) - { - var grid = _gridContext.Geometry; - var widthCells = Math.Clamp(placement.GridWidthCells ?? 1, 1, Math.Max(1, grid.ColumnCount)); - var heightCells = Math.Clamp(placement.GridHeightCells ?? 1, 1, Math.Max(1, grid.RowCount)); - var column = Math.Clamp(placement.GridColumn ?? 0, 0, Math.Max(0, grid.ColumnCount - widthCells)); - var row = Math.Clamp(placement.GridRow ?? 0, 0, Math.Max(0, grid.RowCount - heightCells)); - var rect = DesktopPlacementMath.GetCellRect(grid, column, row, widthCells, heightCells); - - placement.GridColumn = column; - placement.GridRow = row; - placement.GridWidthCells = widthCells; - placement.GridHeightCells = heightCells; - placement.X = rect.X; - placement.Y = rect.Y; - placement.Width = rect.Width; - placement.Height = rect.Height; - } - - private (int WidthCells, int HeightCells) ResolveRequestedSpan( - DesktopComponentDefinition definition, - double? requestedWidth, - double? requestedHeight) - { - var widthCells = requestedWidth.HasValue ? PixelSizeToCellSpan(requestedWidth.Value) : definition.MinWidthCells; - var heightCells = requestedHeight.HasValue ? PixelSizeToCellSpan(requestedHeight.Value) : definition.MinHeightCells; - (widthCells, heightCells) = ComponentPlacementRules.EnsureMinimumSize(definition, widthCells, heightCells); - widthCells = Math.Clamp(widthCells, 1, Math.Max(1, _gridContext.Geometry.ColumnCount)); - heightCells = Math.Clamp(heightCells, 1, Math.Max(1, _gridContext.Geometry.RowCount)); - return (widthCells, heightCells); - } - - private (int Column, int Row) ResolveRequestedCell(double x, double y, int widthCells, int heightCells) - { - var grid = _gridContext.Geometry; - if (double.IsNaN(x) || double.IsNaN(y)) - { - return ( - Math.Max(0, (grid.ColumnCount - widthCells) / 2), - Math.Max(0, (grid.RowCount - heightCells) / 2)); - } - - var column = PixelPositionToCell(x, grid.Origin.X); - var row = PixelPositionToCell(y, grid.Origin.Y); - return ( - Math.Clamp(column, 0, Math.Max(0, grid.ColumnCount - widthCells)), - Math.Clamp(row, 0, Math.Max(0, grid.RowCount - heightCells))); - } - - private int PixelSizeToCellSpan(double pixels) - { - var grid = _gridContext.Geometry; - var pitch = Math.Max(1, grid.Pitch); - var span = (int)Math.Round((Math.Max(1, pixels) + grid.CellGap) / pitch); - return Math.Max(1, span); - } - - private int PixelPositionToCell(double position, double origin) - { - var pitch = Math.Max(1, _gridContext.Geometry.Pitch); - return (int)Math.Round((position - origin) / pitch); - } - - private void UpdateInteractiveRegions() - { - _interactiveRegions.Clear(); - - foreach (var host in _componentHosts.Values) - { - var left = Canvas.GetLeft(host); - var top = Canvas.GetTop(host); - var width = host.Width > 0 ? host.Width : host.Bounds.Width; - var height = host.Height > 0 ? host.Height : host.Bounds.Height; - if (width > 0 && height > 0) - { - _interactiveRegions.Add(new Rect(left - 14, top - 14, width + 28, height + 28)); - } - } - - if (EditToolbar.IsVisible && - EditToolbar.Bounds.Width > 0 && - EditToolbar.Bounds.Height > 0 && - EditToolbar.TranslatePoint(default, this) is { } toolbarOrigin) - { - _interactiveRegions.Add(new Rect(toolbarOrigin, EditToolbar.Bounds.Size)); - } - - _regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions); - } - - private void SaveLayout() - { - _layoutService.Save(_layout); - } - - private void OnCanvasPointerPressed(object? sender, PointerPressedEventArgs e) - { - if (e.Source == ComponentCanvas) - { - DeselectComponent(); - } - } - - private void OnExitEditClick(object? sender, RoutedEventArgs e) - { - ExitEditRequested?.Invoke(this, EventArgs.Empty); - e.Handled = true; - } - - private void OnRestoreComponentLibraryClick(object? sender, RoutedEventArgs e) - { - RestoreComponentLibraryRequested?.Invoke(this, EventArgs.Empty); - e.Handled = true; - } - - private void SelectComponent(Border host) - { - if (_selectedHost == host) - { - return; - } - - DeselectComponent(); - _selectedHost = host; - host.Classes.Add("selected"); - SetResizeHandleVisible(host, true); - } - - private void DeselectComponent() - { - if (_selectedHost is null) - { - return; - } - - _selectedHost.Classes.Remove("selected"); - SetResizeHandleVisible(_selectedHost, false); - _selectedHost = null; - } - - private static void SetResizeHandleVisible(Border host, bool isVisible) - { - if (host.Child is not Grid grid) - { - return; - } - - foreach (var child in grid.Children) - { - if (child is Control control && control.Tag as string == ResizeHandleTag) - { - control.IsVisible = isVisible; - control.IsHitTestVisible = isVisible; - return; - } - } - } - - private void OnComponentContextRequested(object? sender, ContextRequestedEventArgs e) - { - if (sender is not Border host || host.Tag is not string placementId) - { - return; - } - - var deleteItem = new MenuItem - { - Header = "移除组件" - }; - deleteItem.Click += (_, _) => RemoveComponent(placementId); - - var menu = new ContextMenu - { - Items = { deleteItem } - }; - menu.Open(host); - e.Handled = true; - } - - private void OnComponentPointerPressed(object? sender, PointerPressedEventArgs e) - { - if (sender is not Border host || - host.Tag is not string placementId || - !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) - { - return; - } - - var placement = _layout.ComponentPlacements.Find(p => - string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); - if (placement is null || placement.IsLocked) - { - return; - } - - EnsurePlacementGridFields(placement); - SelectComponent(host); - - if (e.Source is Control sourceControl && sourceControl.Tag as string == ResizeHandleTag) - { - BeginResizeInteraction(host, placement, e); - } - else - { - BeginMoveInteraction(host, placement, e); - } - - if (_editSession.IsActive) - { - e.Pointer.Capture(host); - e.Handled = true; - } - } - - private void BeginMoveInteraction(Border host, FusedDesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) - { - var pointer = e.GetPosition(this); - _interactionHost = host; - _interactionPlacementId = placement.PlacementId; - _interactionStartRow = placement.GridRow ?? 0; - _interactionStartColumn = placement.GridColumn ?? 0; - _interactionOriginalRect = DesktopPlacementMath.GetCellRect( - _gridContext.Geometry, - _interactionStartColumn, - _interactionStartRow, - placement.GridWidthCells ?? 1, - placement.GridHeightCells ?? 1); - - var pointerOffset = DesktopPlacementMath.Subtract( - pointer, - new Point(_interactionOriginalRect.X, _interactionOriginalRect.Y)); - _editSession = DesktopEditSession.CreateDraggingExisting( - placement.ComponentId, - placement.PlacementId, - pageIndex: 0, - placement.GridWidthCells ?? 1, - placement.GridHeightCells ?? 1, - pointer, - pointerOffset, - componentLibraryBounds: null); - } - - private void BeginResizeInteraction(Border host, FusedDesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) - { - if (_componentRuntimeRegistry is null || - !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) - { - return; - } - - var startSpan = ComponentPlacementRules.EnsureMinimumSize( - descriptor.Definition, - placement.GridWidthCells ?? 1, - placement.GridHeightCells ?? 1); - var minSpan = ComponentPlacementRules.EnsureMinimumSize( - descriptor.Definition, - descriptor.Definition.MinWidthCells, - descriptor.Definition.MinHeightCells); - var column = placement.GridColumn ?? 0; - var row = placement.GridRow ?? 0; - var maxWidthCells = Math.Max(startSpan.WidthCells, _gridContext.Geometry.ColumnCount - column); - var maxHeightCells = Math.Max(startSpan.HeightCells, _gridContext.Geometry.RowCount - row); - - _interactionHost = host; - _interactionPlacementId = placement.PlacementId; - _interactionStartRow = row; - _interactionStartColumn = column; - _interactionStartWidthCells = startSpan.WidthCells; - _interactionStartHeightCells = startSpan.HeightCells; - _interactionMinWidthCells = Math.Max(1, Math.Min(minSpan.WidthCells, maxWidthCells)); - _interactionMinHeightCells = Math.Max(1, Math.Min(minSpan.HeightCells, maxHeightCells)); - _interactionMaxWidthCells = Math.Max(_interactionMinWidthCells, maxWidthCells); - _interactionMaxHeightCells = Math.Max(_interactionMinHeightCells, maxHeightCells); - _interactionResizeMode = descriptor.Definition.ResizeMode; - _interactionOriginalRect = DesktopPlacementMath.GetCellRect( - _gridContext.Geometry, - column, - row, - startSpan.WidthCells, - startSpan.HeightCells); - - _editSession = DesktopEditSession.CreateResizingExisting( - placement.ComponentId, - placement.PlacementId, - pageIndex: 0, - startSpan.WidthCells, - startSpan.HeightCells, - e.GetPosition(this), - componentLibraryBounds: null) with - { - TargetRow = row, - TargetColumn = column - }; - } - - private void OnInteractionPointerMoved(object? sender, PointerEventArgs e) - { - if (!_editSession.IsActive || _interactionHost is null) - { - return; - } - - _editSession = _editSession.WithCurrentPointer(e.GetPosition(this)); - if (_editSession.IsDraggingExisting) - { - UpdateMoveInteraction(); - } - else if (_editSession.IsResizingExisting) - { - UpdateResizeInteraction(); - } - - e.Handled = true; - } - - private void UpdateMoveInteraction() - { - if (_interactionHost is null) - { - return; - } - - var hasSnap = DesktopPlacementMath.TryGetSnappedCell( - _gridContext.Geometry, - _editSession.CurrentPointerInViewport, - _editSession.PointerOffsetInViewport, - _editSession.WidthCells, - _editSession.HeightCells, - out var column, - out var row); - if (!hasSnap) - { - return; - } - - _editSession = _editSession.WithTargetCell(row, column); - var rect = DesktopPlacementMath.GetCellRect( - _gridContext.Geometry, - column, - row, - _editSession.WidthCells, - _editSession.HeightCells); - ApplyHostRect(_interactionHost, rect); - UpdateInteractiveRegions(); - } - - private void UpdateResizeInteraction() - { - if (_interactionHost is null) - { - return; - } - - var deltaX = _editSession.CurrentPointerInViewport.X - _editSession.StartPointerInViewport.X; - var deltaY = _editSession.CurrentPointerInViewport.Y - _editSession.StartPointerInViewport.Y; - int widthCells; - int heightCells; - - if (_interactionResizeMode == DesktopComponentResizeMode.Free) - { - widthCells = Math.Clamp( - (int)Math.Round(_interactionStartWidthCells + deltaX / _gridContext.Geometry.Pitch), - _interactionMinWidthCells, - _interactionMaxWidthCells); - heightCells = Math.Clamp( - (int)Math.Round(_interactionStartHeightCells + deltaY / _gridContext.Geometry.Pitch), - _interactionMinHeightCells, - _interactionMaxHeightCells); - } - else - { - var widthScale = (_interactionOriginalRect.Width + deltaX) / Math.Max(1, _interactionOriginalRect.Width); - var heightScale = (_interactionOriginalRect.Height + deltaY) / Math.Max(1, _interactionOriginalRect.Height); - var proposedScale = Math.Max(widthScale, heightScale); - if (double.IsNaN(proposedScale) || double.IsInfinity(proposedScale)) - { - proposedScale = 1; - } - - var minScale = Math.Max( - (double)_interactionMinWidthCells / Math.Max(1, _interactionStartWidthCells), - (double)_interactionMinHeightCells / Math.Max(1, _interactionStartHeightCells)); - var maxScale = Math.Min( - (double)_interactionMaxWidthCells / Math.Max(1, _interactionStartWidthCells), - (double)_interactionMaxHeightCells / Math.Max(1, _interactionStartHeightCells)); - if (maxScale < minScale) - { - maxScale = minScale; - } - - var scale = Math.Clamp(proposedScale, minScale, maxScale); - widthCells = Math.Clamp( - (int)Math.Round(_interactionStartWidthCells * scale), - _interactionMinWidthCells, - _interactionMaxWidthCells); - heightCells = Math.Clamp( - (int)Math.Round(_interactionStartHeightCells * scale), - _interactionMinHeightCells, - _interactionMaxHeightCells); - } - - _editSession = _editSession with - { - WidthCells = Math.Max(1, widthCells), - HeightCells = Math.Max(1, heightCells), - TargetRow = _interactionStartRow, - TargetColumn = _interactionStartColumn - }; - - var rect = DesktopPlacementMath.GetCellRect( - _gridContext.Geometry, - _interactionStartColumn, - _interactionStartRow, - _editSession.WidthCells, - _editSession.HeightCells); - ApplyHostRect(_interactionHost, rect); - UpdateInteractiveRegions(); - } - - private void OnInteractionPointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_editSession.IsActive || _interactionHost is null || _interactionPlacementId is null) - { - ResetInteraction(); - return; - } - - CompleteInteraction(); - e.Pointer.Capture(null); - e.Handled = true; - } - - private void OnInteractionPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) - { - if (!_editSession.IsActive || _interactionHost is null) - { - return; - } - - CompleteInteraction(); - } - - private void CompleteInteraction() - { - if (_interactionPlacementId is null) - { - ResetInteraction(); - return; - } - - var placement = _layout.ComponentPlacements.Find(p => - string.Equals(p.PlacementId, _interactionPlacementId, StringComparison.OrdinalIgnoreCase)); - if (placement is not null && _editSession.HasTargetCell) - { - placement.GridRow = _editSession.TargetRow; - placement.GridColumn = _editSession.TargetColumn; - placement.GridWidthCells = Math.Max(1, _editSession.WidthCells); - placement.GridHeightCells = Math.Max(1, _editSession.HeightCells); - ApplyGridPlacementToPixelPlacement(placement); - if (_interactionHost is not null) - { - ApplyHostRect(_interactionHost, new Rect(placement.X, placement.Y, placement.Width, placement.Height)); - } - - SaveLayout(); - } - - UpdateInteractiveRegions(); - ResetInteraction(); - } - - private void ResetInteraction() - { - _editSession = default; - _interactionHost = null; - _interactionPlacementId = null; - _interactionOriginalRect = default; - _interactionStartRow = 0; - _interactionStartColumn = 0; - _interactionStartWidthCells = 0; - _interactionStartHeightCells = 0; - _interactionMinWidthCells = 0; - _interactionMinHeightCells = 0; - _interactionMaxWidthCells = 0; - _interactionMaxHeightCells = 0; - _interactionResizeMode = DesktopComponentResizeMode.Proportional; - } - - private static void ApplyHostRect(Border host, Rect rect) - { - Canvas.SetLeft(host, rect.X); - Canvas.SetTop(host, rect.Y); - host.Width = Math.Max(1, rect.Width); - host.Height = Math.Max(1, rect.Height); - if (host.Child is Grid grid && grid.Children.Count > 0 && grid.Children[0] is Control component) - { - component.Width = host.Width; - component.Height = host.Height; - } - } - - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - if (!appSnapshot.EnableThreeFingerSwipe) - { - base.OnPointerPressed(e); - return; - } - - if (!TryGetPointerPosition(e, out var pointerPos)) - { - base.OnPointerPressed(e); - return; - } - - var currentPoint = e.GetCurrentPoint(this); - var pointerId = e.Pointer?.Id ?? 0; - var isRightButtonPressed = currentPoint.Properties.IsRightButtonPressed; - var isLeftButtonPressed = currentPoint.Properties.IsLeftButtonPressed; - - if (isLeftButtonPressed || isRightButtonPressed) - { - _activePointerIds.Add(pointerId); - } - - var isThreeFinger = _activePointerIds.Count >= 3; - var isRightDrag = isRightButtonPressed; - - if (isThreeFinger || isRightDrag) - { - _isSwipeActive = true; - _isThreeFingerOrRightDragSwipeActive = true; - _isSwipeDirectionLocked = false; - _swipeStartPoint = pointerPos; - _swipeCurrentPoint = pointerPos; - _swipeLastPoint = pointerPos; - _swipeVelocityX = 0; - _swipeLastTimestamp = Stopwatch.GetTimestamp(); - _swipePointerId = pointerId; - e.Handled = true; - } - else - { - base.OnPointerPressed(e); - } - } - - protected override void OnPointerMoved(PointerEventArgs e) - { - if (_isSwipeActive && !IsSwipePointer(e.Pointer)) - { - base.OnPointerMoved(e); - return; - } - - if (!_isSwipeActive) - { - base.OnPointerMoved(e); - return; - } - - if (!TryGetPointerPosition(e, out var pointerPos)) - { - base.OnPointerMoved(e); - return; - } - - _swipeCurrentPoint = pointerPos; - UpdateSwipeVelocity(pointerPos); - - var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; - var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; - - if (!_isSwipeDirectionLocked) - { - const double activationThreshold = 14; - const double horizontalBias = 1.15; - var absDeltaX = Math.Abs(deltaX); - var absDeltaY = Math.Abs(deltaY); - - if (absDeltaY >= activationThreshold && absDeltaY > absDeltaX * horizontalBias) - { - CancelSwipeInteraction(e.Pointer); - base.OnPointerMoved(e); - return; - } - - if (absDeltaX < activationThreshold || absDeltaX <= absDeltaY * horizontalBias) - { - base.OnPointerMoved(e); - return; - } - - _isSwipeDirectionLocked = true; - if (e.Pointer?.Captured != this) - { - e.Pointer?.Capture(this); - } - } - - e.Handled = true; - } - - protected override void OnPointerReleased(PointerReleasedEventArgs e) - { - var pointerId = e.Pointer?.Id ?? 0; - _activePointerIds.Remove(pointerId); - - if (_isSwipeActive && !IsSwipePointer(e.Pointer)) - { - base.OnPointerReleased(e); - return; - } - - if (_isSwipeActive && EndSwipeInteraction(e.Pointer)) - { - e.Handled = true; - return; - } - - base.OnPointerReleased(e); - } - - protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) - { - var pointerId = e.Pointer?.Id ?? 0; - _activePointerIds.Remove(pointerId); - - if (_isSwipeActive && !IsSwipePointer(e.Pointer)) - { - base.OnPointerCaptureLost(e); - return; - } - - if (_isSwipeActive && e.Pointer?.Captured == this) - { - base.OnPointerCaptureLost(e); - return; - } - - if (_isSwipeActive) - { - EndSwipeInteraction(e.Pointer); - } - - base.OnPointerCaptureLost(e); - } - - private bool TryGetPointerPosition(PointerEventArgs e, out Point point) - { - try - { - point = e.GetPosition(this); - return true; - } - catch - { - point = default; - return false; - } - } - - private bool IsSwipePointer(IPointer? pointer) - { - return !_swipePointerId.HasValue || - pointer is not null && pointer.Id == _swipePointerId.Value; - } - - private void UpdateSwipeVelocity(Point currentPoint) - { - var now = Stopwatch.GetTimestamp(); - var elapsed = Stopwatch.GetElapsedTime(_swipeLastTimestamp, now).TotalSeconds; - if (elapsed > 0) - { - _swipeVelocityX = (currentPoint.X - _swipeLastPoint.X) / elapsed; - } - - _swipeLastPoint = currentPoint; - _swipeLastTimestamp = now; - } - - private void CancelSwipeInteraction(IPointer? pointer) - { - if (!_isSwipeActive) - { - return; - } - - if (pointer?.Captured == this) - { - pointer.Capture(null); - } - - _isSwipeActive = false; - _isSwipeDirectionLocked = false; - _isThreeFingerOrRightDragSwipeActive = false; - _activePointerIds.Clear(); - _swipePointerId = null; - _swipeVelocityX = 0; - _swipeLastTimestamp = 0; - } - - private bool EndSwipeInteraction(IPointer? pointer) - { - if (!_isSwipeActive) - { - return false; - } - - var wasDirectionLocked = _isSwipeDirectionLocked; - var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive; - - _isSwipeActive = false; - _isSwipeDirectionLocked = false; - _isThreeFingerOrRightDragSwipeActive = false; - _activePointerIds.Clear(); - _swipePointerId = null; - - if (pointer?.Captured == this) - { - pointer.Capture(null); - } - - _swipeLastTimestamp = 0; - - if (!wasDirectionLocked) - { - _swipeVelocityX = 0; - return false; - } - - var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; - var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; - var absDeltaX = Math.Abs(deltaX); - var distanceThreshold = Math.Max(48, Bounds.Width * 0.14); - var velocityThreshold = Math.Max(860, Bounds.Width * 1.08); - var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > Math.Abs(deltaY) * 1.05; - var hasVelocityIntent = Math.Abs(_swipeVelocityX) >= velocityThreshold; - - if (wasThreeFingerOrRightDrag && deltaX < 0 && (hasDistanceIntent || hasVelocityIntent)) - { - RestoreMainWindowRequested?.Invoke(this, EventArgs.Empty); - _swipeVelocityX = 0; - return true; - } - - _swipeVelocityX = 0; - return hasDistanceIntent || hasVelocityIntent; - } -} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlatformPublishResult.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlatformPublishResult.cs deleted file mode 100644 index 326ccb0..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlatformPublishResult.cs +++ /dev/null @@ -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 InstallerFiles); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsBuildOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsBuildOptions.cs deleted file mode 100644 index 366115e..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsBuildOptions.cs +++ /dev/null @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitAnalyzer.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitAnalyzer.cs new file mode 100644 index 0000000..f9d8023 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitAnalyzer.cs @@ -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 GetChangedSourceFiles(string baselineTag, string currentTag) + { + var changedFiles = new HashSet(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 MapSourceFilesToArtifacts(IReadOnlySet sourceFiles) + { + var artifacts = new HashSet(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; + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuildOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuildOptions.cs new file mode 100644 index 0000000..99cfeee --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuildOptions.cs @@ -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"); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuildResult.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuildResult.cs new file mode 100644 index 0000000..b31c4b7 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuildResult.cs @@ -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 ChangedSourceFiles, + IReadOnlyList MappedArtifactFiles); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuilder.cs new file mode 100644 index 0000000..66b8ea7 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuilder.cs @@ -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(StringComparer.OrdinalIgnoreCase); + var changedFilesMap = new Dictionary(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 + { + ["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(StringComparer.OrdinalIgnoreCase); + var changedFilesMap = new Dictionary(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 + { + ["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 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(); + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs index 12f492d..e67a40c 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs @@ -1,16 +1,12 @@ -namespace Plonds.Core.Publishing; +namespace Plonds.Core.Publishing; public sealed record PlondsDeltaBuildOptions( string Platform, string CurrentVersion, - string CurrentTag, string CurrentPayloadZip, string OutputRoot, - string PrivateKeyPath, string Channel = "stable", string? BaselineVersion = null, - string? BaselineTag = null, string? BaselinePayloadZip = null, - bool IsFullPayload = false, - string? StaticOutputRoot = null, - string? UpdateBaseUrl = null); + string LauncherRelativePath = "LanMountainDesktop.Launcher.exe", + string HashAlgorithm = "sha256"); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs index 84c4b09..8ff28a4 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs @@ -1,13 +1,10 @@ -namespace Plonds.Core.Publishing; +namespace Plonds.Core.Publishing; public sealed record PlondsDeltaBuildResult( string Platform, - string DistributionId, - string UpdateArchivePath, - string FileMapPath, - string FileMapSignaturePath, - string SummaryPath, - bool IsFullPayload, - string? BaselineTag, - string? BaselineVersion, - string TargetVersion); + string ChangedZipPath, + string ManifestPath, + bool IsFullUpdate, + bool RequiresCleanInstall, + string CurrentVersion, + string? BaselineVersion); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs index ec7dc6b..5af200f 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs @@ -1,16 +1,24 @@ -using Plonds.Core.Security; +using System.Security.Cryptography; +using System.Text.Json; +using Plonds.Shared; using Plonds.Shared.Models; namespace Plonds.Core.Publishing; public sealed class PlondsDeltaBuilder { - private readonly RsaFileSigner _signer = new(); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options) { ArgumentNullException.ThrowIfNull(options); + var hashAlgorithm = ValidateHashAlgorithmInternal(options.HashAlgorithm); + var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip); if (!File.Exists(currentPayloadZip)) { @@ -29,332 +37,200 @@ public sealed class PlondsDeltaBuilder var workRoot = Path.Combine(outputRoot, "work", options.Platform); var currentExtractRoot = Path.Combine(workRoot, "current"); var baselineExtractRoot = Path.Combine(workRoot, "baseline"); - var objectsRoot = Path.Combine(workRoot, "objects"); - var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets"); - var summaryRoot = Path.Combine(outputRoot, "platform-summaries"); - Directory.CreateDirectory(releaseAssetsRoot); - Directory.CreateDirectory(summaryRoot); + Directory.CreateDirectory(outputRoot); PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot); - var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip); - if (useFullPayload) - { - PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot); - } - else + var isFullUpdate = string.IsNullOrWhiteSpace(baselinePayloadZip); + if (!isFullUpdate) { PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot); } - PayloadUtilities.EnsureCleanDirectory(objectsRoot); - - var previousManifest = useFullPayload + var previousManifest = isFullUpdate ? new Dictionary(StringComparer.OrdinalIgnoreCase) : PayloadUtilities.ScanDirectory(baselineExtractRoot); var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot); - var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl) - ? null - : options.UpdateBaseUrl.TrimEnd('/'); - var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl) - ? null - : $"{updateBaseUrl}/repo/sha256"; - var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl); - var updateAssetName = $"update-{options.Platform}.zip"; - var fileMapAssetName = $"plonds-filemap-{options.Platform}.json"; - var fileMapSignatureAssetName = fileMapAssetName + ".sig"; - var distributionId = $"plonds-{options.CurrentVersion}-{options.Platform}"; - var updateArchivePath = Path.Combine(releaseAssetsRoot, updateAssetName); - var fileMapPath = Path.Combine(releaseAssetsRoot, fileMapAssetName); - var fileMapSignaturePath = Path.Combine(releaseAssetsRoot, fileMapSignatureAssetName); + var filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm); + var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm); - PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath); + var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform); - var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["protocol"] = "PLONDS", - ["channel"] = options.Channel, - ["releaseTag"] = options.CurrentTag, - ["baselineTag"] = options.BaselineTag ?? string.Empty, - ["baselineVersion"] = options.BaselineVersion ?? "0.0.0", - ["targetVersion"] = options.CurrentVersion, - ["isFullPayload"] = useFullPayload ? "true" : "false" - }; + var launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath); + var requiresCleanInstall = launcherChanged && !isFullUpdate; - var generatedAt = DateTimeOffset.UtcNow; - var component = new ComponentDocument( - Name: "app", - Version: options.CurrentVersion, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["component"] = "app", - ["mode"] = "file-object" - }, - Files: fileEntries); + var changedZipMd5 = ComputeMd5Hex(changedZipPath); - var fileMap = new FileMapDocument( - FormatVersion: "1.0", - DistributionId: distributionId, - FromVersion: options.BaselineVersion ?? "0.0.0", - ToVersion: options.CurrentVersion, - Version: options.CurrentVersion, - Platform: options.Platform, - Arch: PayloadUtilities.ResolveArch(options.Platform), - Channel: options.Channel, - GeneratedAt: generatedAt, - Metadata: metadata, - Components: [component], - Files: fileEntries); - - PayloadUtilities.WriteJson(fileMapPath, fileMap); - _signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath); - - if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl)) - { - WriteStaticLayout( - options, - component, - objectsRoot, - distributionId, - fileMapPath, - fileMapSignaturePath, - Path.GetFullPath(options.StaticOutputRoot), - updateBaseUrl, - generatedAt); - } - - var summary = new PlondsReleasePlatformEntry( - Platform: options.Platform, - DistributionId: distributionId, - BaselineTag: options.BaselineTag, - BaselineVersion: options.BaselineVersion ?? "0.0.0", - TargetVersion: options.CurrentVersion, - IsFullPayload: useFullPayload, - FilesZipAsset: $"files-{options.Platform}.zip", - UpdateZipAsset: updateAssetName, - FileMapAsset: fileMapAssetName, - FileMapSignatureAsset: fileMapSignatureAssetName, - Sha256: PayloadUtilities.ComputeSha256(updateArchivePath)); - - var summaryPath = Path.Combine(summaryRoot, $"platform-summary-{options.Platform}.json"); - PayloadUtilities.WriteJson(summaryPath, summary); - - return new PlondsDeltaBuildResult( - options.Platform, - distributionId, - updateArchivePath, - fileMapPath, - fileMapSignaturePath, - summaryPath, - useFullPayload, - options.BaselineTag, - options.BaselineVersion, - options.CurrentVersion); - } - - private static List BuildFileEntries( - IReadOnlyDictionary previousManifest, - IReadOnlyDictionary currentManifest, - string objectsRoot, - string? repoBaseUrl) - { - var result = new List(); - - 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(StringComparer.OrdinalIgnoreCase) - { - ["mode"] = "file-object" - }; - if (!string.IsNullOrWhiteSpace(current.UnixFileMode)) - { - metadata["unixFileMode"] = current.UnixFileMode!; - } - - result.Add(new FileEntryDocument( - Path: path, - Action: action, - Sha256: current.Sha256, - Size: current.Size, - ObjectPath: objectPath, - ObjectKey: objectPath, - ObjectUrl: objectUrl, - Metadata: metadata)); - } - - foreach (var path in previousManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) - { - if (currentManifest.ContainsKey(path)) - { - continue; - } - - result.Add(new FileEntryDocument( - Path: path, - Action: "delete", - Sha256: string.Empty, - Size: 0, - ObjectPath: null, - ObjectKey: null, - ObjectUrl: null, - Metadata: null)); - } - - return result; - } - - private static void WriteStaticLayout( - PlondsDeltaBuildOptions options, - ComponentDocument component, - string objectsRoot, - string distributionId, - string fileMapPath, - string fileMapSignaturePath, - string staticOutputRoot, - string updateBaseUrl, - DateTimeOffset generatedAt) - { - var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256"); - var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId); - var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions"); - var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform); - - CopyDirectory(objectsRoot, repoRoot); - Directory.CreateDirectory(manifestRoot); - File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true); - File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true); - - var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json"; - var distribution = new DistributionDocument( - DistributionId: distributionId, - Version: options.CurrentVersion, - SourceVersion: options.BaselineVersion ?? "0.0.0", + var manifest = new PlondsManifest( + FormatVersion: PlondsConstants.FormatVersion, + CurrentVersion: options.CurrentVersion, + PreviousVersion: options.BaselineVersion ?? "0.0.0", + IsFullUpdate: isFullUpdate, + RequiresCleanInstall: requiresCleanInstall, Channel: options.Channel, Platform: options.Platform, - Arch: PayloadUtilities.ResolveArch(options.Platform), - PublishedAt: generatedAt, - FileMapUrl: fileMapUrl, - FileMapSignatureUrl: fileMapUrl + ".sig", - Components: [component], - InstallerMirrors: [], - Capabilities: ["file-object"], - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + UpdatedAt: DateTimeOffset.UtcNow, + CompareMethod: PlondsConstants.CompareMethodFileCompare, + HashAlgorithm: hashAlgorithm, + FilesMap: filesMap, + ChangedFilesMap: changedFilesMap, + Checksums: new Dictionary { - ["protocol"] = "PLONDS", - ["releaseTag"] = options.CurrentTag, - ["baselineTag"] = options.BaselineTag ?? string.Empty, - ["baselineVersion"] = options.BaselineVersion ?? "0.0.0", - ["targetVersion"] = options.CurrentVersion, - ["isFullPayload"] = options.IsFullPayload ? "true" : "false" + ["changed.zip"] = $"md5:{changedZipMd5}" }); - var latest = new LatestPointerDocument( - DistributionId: distributionId, - Version: options.CurrentVersion, - Channel: options.Channel, + var manifestPath = Path.Combine(outputRoot, "PLONDS.json"); + var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); + File.WriteAllText(manifestPath, manifestJson); + + return new PlondsDeltaBuildResult( Platform: options.Platform, - PublishedAt: generatedAt); - - PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution); - PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest); + ChangedZipPath: changedZipPath, + ManifestPath: manifestPath, + IsFullUpdate: isFullUpdate, + RequiresCleanInstall: requiresCleanInstall, + CurrentVersion: options.CurrentVersion, + BaselineVersion: options.BaselineVersion); } - private static void CopyDirectory(string sourceDir, string destinationDir) + internal static string ValidateHashAlgorithmInternal(string algorithm) { - Directory.CreateDirectory(destinationDir); - foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories)) + var normalized = algorithm.Trim().ToLowerInvariant(); + if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5)) { - var relativePath = Path.GetRelativePath(sourceDir, directory); - Directory.CreateDirectory(Path.Combine(destinationDir, relativePath)); + throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5"); } - foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(sourceDir, file); - var destinationPath = Path.Combine(destinationDir, relativePath); - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - File.Copy(file, destinationPath, overwrite: true); - } + return normalized; } - private sealed record FileMapDocument( - string FormatVersion, - string DistributionId, - string FromVersion, - string ToVersion, - string Version, - string Platform, - string Arch, - string Channel, - DateTimeOffset GeneratedAt, - IReadOnlyDictionary Metadata, - IReadOnlyList Components, - IReadOnlyList Files); + private static Dictionary BuildFilesMap( + IReadOnlyDictionary previousManifest, + IReadOnlyDictionary currentManifest, + string hashAlgorithm) + { + var filesMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - private sealed record ComponentDocument( - string Name, - string Version, - IReadOnlyDictionary? Metadata, - IReadOnlyList Files); + foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + var current = currentManifest[path]; + var currentHash = GetHash(current, hashAlgorithm); - private sealed record FileEntryDocument( - string Path, - string Action, - string Sha256, - long Size, - string? ObjectPath, - string? ObjectKey, - string? ObjectUrl, - IReadOnlyDictionary? Metadata); + if (previousManifest.TryGetValue(path, out var previous)) + { + var previousHash = GetHash(previous, hashAlgorithm); + if (string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase)) + { + filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReuse, currentHash, current.Size, hashAlgorithm); + continue; + } + } - private sealed record DistributionDocument( - string DistributionId, - string Version, - string SourceVersion, - string Channel, - string Platform, - string Arch, - DateTimeOffset PublishedAt, - string FileMapUrl, - string FileMapSignatureUrl, - IReadOnlyList Components, - IReadOnlyList InstallerMirrors, - IReadOnlyList Capabilities, - IReadOnlyDictionary? Metadata); + var action = previousManifest.ContainsKey(path) + ? PlondsConstants.ActionReplace + : PlondsConstants.ActionAdd; + filesMap[path] = new PlondsFileEntry(action, currentHash, current.Size, hashAlgorithm); + } - private sealed record LatestPointerDocument( - string DistributionId, - string Version, - string Channel, - string Platform, - DateTimeOffset PublishedAt); + foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + if (!currentManifest.ContainsKey(path)) + { + filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionDelete, string.Empty, 0, hashAlgorithm); + } + } - private sealed record InstallerMirrorDocument( - string Platform, - string? Url, - string? FileName, - string? Sha256, - long Size); + return filesMap; + } + + private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm) + { + if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5) + { + return ComputeMd5Hex(fingerprint.FullPath); + } + + return fingerprint.Sha256; + } + + private static Dictionary BuildChangedFilesMap( + IReadOnlyDictionary filesMap, + string hashAlgorithm) + { + var changedFilesMap = new Dictionary(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 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 previousManifest, + IReadOnlyDictionary 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(); + } } diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs deleted file mode 100644 index aca01a8..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs +++ /dev/null @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs deleted file mode 100644 index bf1887f..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs +++ /dev/null @@ -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(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 { ["component"] = "app" }) - ], - Metadata: new Dictionary - { - ["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 - { - ["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 ScanDirectory(string? root) - { - var manifest = new Dictionary(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 BuildFileEntries( - Dictionary previousManifest, - Dictionary currentManifest, - string repoRoot, - string? repoBaseUrl) - { - var entries = new List(); - - 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 { ["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 { ["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 BuildInstallerMirrors( - string platform, - string installerMirrorRoot, - string? installerSourceDirectory, - string? installerBaseUrl) - { - var result = new List(); - 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(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 Capabilities, - IReadOnlyList Components, - IReadOnlyDictionary? Metadata); - - private sealed record DistributionDocument( - string DistributionId, - string Version, - string Channel, - string Platform, - string Arch, - DateTimeOffset PublishedAt, - string? FileMapUrl, - string? FileMapSignatureUrl, - IReadOnlyList Components, - IReadOnlyList InstallerMirrors, - IReadOnlyList Capabilities, - IReadOnlyDictionary? 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 Files, - IReadOnlyDictionary? Metadata); - - private sealed record FileEntryDocument( - string Path, - string Action, - string Sha256, - long Size, - string Mode, - string? ObjectKey, - string? ObjectUrl, - string? ArchiveSha256, - IReadOnlyDictionary? 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); -} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsManifestBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsManifestBuilder.cs deleted file mode 100644 index 5e1c429..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsManifestBuilder.cs +++ /dev/null @@ -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 - { - 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); - } -} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs deleted file mode 100644 index 7ee4092..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs +++ /dev/null @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs deleted file mode 100644 index 909a576..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs +++ /dev/null @@ -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 Publish(PlondsPublishOptions options) - { - ArgumentNullException.ThrowIfNull(options); - - var results = new List(); - 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 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 - { - ["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 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 InstallerExtensions, - IReadOnlyList FileNameTokens); -} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexBuilder.cs deleted file mode 100644 index bb92d47..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexBuilder.cs +++ /dev/null @@ -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(json, PayloadUtilities.JsonOptions); - if (summary is null) - { - throw new InvalidOperationException($"Unable to deserialize PLONDS platform summary: {path}"); - } - - return summary; - } -} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexOptions.cs deleted file mode 100644 index e0c22ae..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Plonds.Core.Publishing; - -public sealed record PlondsReleaseIndexOptions( - string ReleaseTag, - string Version, - string Channel, - string PlatformSummariesDirectory, - string OutputRoot, - string PrivateKeyPath); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Security/RsaFileSigner.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Security/RsaFileSigner.cs deleted file mode 100644 index f6543a7..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Security/RsaFileSigner.cs +++ /dev/null @@ -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; - } -} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsAssetEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsAssetEntry.cs deleted file mode 100644 index 69f1473..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsAssetEntry.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsAssetEntry( - string AssetId, - string FileName, - string Sha256, - long Size, - IReadOnlyList Mirrors); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChangedFileEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChangedFileEntry.cs new file mode 100644 index 0000000..2cf15c6 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChangedFileEntry.cs @@ -0,0 +1,7 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsChangedFileEntry( + string ArchivePath, + string Hash, + long Size, + string HashAlgorithm = "sha256"); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChannelPointer.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChannelPointer.cs deleted file mode 100644 index 3a83bb0..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChannelPointer.cs +++ /dev/null @@ -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); - diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsComponent.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsComponent.cs deleted file mode 100644 index 796f04c..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsComponent.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsComponent( - string Id, - string Root, - string Mode, - IReadOnlyList Files, - IReadOnlyDictionary? Metadata = null); - diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDistributionInfo.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDistributionInfo.cs deleted file mode 100644 index 099c97d..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDistributionInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsDistributionInfo( - string DistributionId, - string Version, - string Channel, - string Platform, - DateTimeOffset PublishedAt, - IReadOnlyList Components, - IReadOnlyList InstallerMirrors, - IReadOnlyList Capabilities, - IReadOnlyList Signatures, - IReadOnlyDictionary? Metadata = null); - diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileEntry.cs index 3b73112..0fcfa9f 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileEntry.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileEntry.cs @@ -1,13 +1,7 @@ namespace Plonds.Shared.Models; public sealed record PlondsFileEntry( - string Path, - string Op, - string ContentHash, + string Action, + string Hash, long Size, - string Mode, - string? ObjectKey = null, - string? Compression = null, - string? PatchBaseHash = null, - string? PatchObjectKey = null); - + string HashAlgorithm = "sha256"); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileMap.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileMap.cs deleted file mode 100644 index d4b885b..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileMap.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsFileMap( - string FormatVersion, - string DistributionId, - string SourceVersion, - string TargetVersion, - string Platform, - IReadOnlyList Components, - IReadOnlyList Capabilities, - IReadOnlyList Signatures, - IReadOnlyDictionary? Metadata = null); - diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs index 4fa67b8..c052313 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs @@ -1,7 +1,19 @@ -namespace Plonds.Shared.Models; +using System.Text.Json.Serialization; + +namespace Plonds.Shared.Models; public sealed record PlondsManifest( string FormatVersion, - string ReleaseTag, - DateTimeOffset GeneratedAt, - IReadOnlyList Assets); + string CurrentVersion, + string PreviousVersion, + bool IsFullUpdate, + bool RequiresCleanInstall, + string Channel, + string Platform, + DateTimeOffset UpdatedAt, + string CompareMethod, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? HashAlgorithm, + IReadOnlyDictionary FilesMap, + IReadOnlyDictionary ChangedFilesMap, + IReadOnlyDictionary Checksums); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMetadataCatalog.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMetadataCatalog.cs deleted file mode 100644 index 12781ba..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMetadataCatalog.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsMetadataCatalog( - string ProtocolName, - string ProtocolVersion, - string StorageRoot, - string MetaRoot, - IReadOnlyList Latest, - IReadOnlyDictionary? Metadata = null); - diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorAsset.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorAsset.cs deleted file mode 100644 index 6a3ef77..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorAsset.cs +++ /dev/null @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorEntry.cs deleted file mode 100644 index afcbd43..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorEntry.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsMirrorEntry( - string Type, - string Url); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleaseManifest.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleaseManifest.cs deleted file mode 100644 index 9ad9359..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleaseManifest.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsReleaseManifest( - string FormatVersion, - string ReleaseTag, - string Version, - string Channel, - DateTimeOffset GeneratedAt, - IReadOnlyList Platforms); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleasePlatformEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleasePlatformEntry.cs deleted file mode 100644 index 4217c5c..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleasePlatformEntry.cs +++ /dev/null @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSignatureDescriptor.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSignatureDescriptor.cs deleted file mode 100644 index 7067f7a..0000000 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSignatureDescriptor.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Plonds.Shared.Models; - -public sealed record PlondsSignatureDescriptor( - string Algorithm, - string KeyId, - string Signature); - diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsConstants.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsConstants.cs index 3578c5c..cecad8f 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsConstants.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsConstants.cs @@ -3,23 +3,39 @@ namespace Plonds.Shared; public static class PlondsConstants { public const string ProtocolName = "PLONDS"; - public const string ProtocolVersion = "1.0"; + public const string ProtocolVersion = "2.0"; + public const string FormatVersion = "2.0"; - public const string DefaultApiBasePath = "/api/plonds/v1"; - public const string DefaultStorageRoot = "sample-data"; - public const string DefaultMetaRoot = "meta"; - public const string DefaultRepoRoot = "repo"; - public const string DefaultInstallersRoot = "installers"; + public const string ActionAdd = "add"; + public const string ActionReplace = "replace"; + public const string ActionReuse = "reuse"; + public const string ActionDelete = "delete"; - public const string FileObjectMode = "file-object"; - public const string CompressedObjectMode = "compressed-object"; - public const string BinaryPatchMode = "binary-patch"; + public const string CompareMethodFileCompare = "file-compare"; + public const string CompareMethodCommitAnalyze = "commit-analyze"; - public static readonly string[] SupportedFileModes = + public const string HashAlgorithmSha256 = "sha256"; + public const string HashAlgorithmMd5 = "md5"; + + public const string DefaultLauncherRelativePath = "LanMountainDesktop.Launcher.exe"; + + public static readonly string[] SupportedActions = [ - FileObjectMode, - CompressedObjectMode, - BinaryPatchMode + ActionAdd, + ActionReplace, + ActionReuse, + ActionDelete + ]; + + public static readonly string[] SupportedHashAlgorithms = + [ + HashAlgorithmSha256, + HashAlgorithmMd5 + ]; + + public static readonly string[] SupportedCompareMethods = + [ + CompareMethodFileCompare, + CompareMethodCommitAnalyze ]; } - diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsFileAction.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsFileAction.cs new file mode 100644 index 0000000..2948831 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsFileAction.cs @@ -0,0 +1,9 @@ +namespace Plonds.Shared; + +public enum PlondsFileAction +{ + Add, + Replace, + Reuse, + Delete +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs index bf49bd0..bf53f3b 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs @@ -1,5 +1,4 @@ -using Plonds.Core.Publishing; -using Plonds.Core.Security; +using Plonds.Core.Publishing; return await PlondsCli.RunAsync(args); @@ -20,26 +19,14 @@ internal static class PlondsCli { switch (command) { - case "generate": - RunGenerate(options); - return Task.FromResult(0); - case "sign": - RunSign(options); - return Task.FromResult(0); - case "publish": - RunPublish(options); - return Task.FromResult(0); - case "pack-payload": - RunPackPayload(options); - return Task.FromResult(0); case "build-delta": RunBuildDelta(options); return Task.FromResult(0); - case "build-index": - RunBuildIndex(options); + case "build-delta-from-commits": + RunBuildDeltaFromCommits(options); return Task.FromResult(0); - case "build-plonds": - RunBuildPlonds(options); + case "pack-payload": + RunPackPayload(options); return Task.FromResult(0); default: Console.Error.WriteLine($"Unknown command: {command}"); @@ -54,63 +41,51 @@ internal static class PlondsCli } } - private static void RunGenerate(Dictionary options) + private static void RunBuildDelta(Dictionary options) { - var generator = new PlondsGenerator(); - var result = generator.Generate(new PlondsGenerateOptions( - CurrentVersion: Require(options, "current-version"), - CurrentDirectory: Require(options, "current-dir"), + var builder = new PlondsDeltaBuilder(); + var result = builder.Build(new PlondsDeltaBuildOptions( Platform: Require(options, "platform"), + CurrentVersion: Require(options, "current-version"), + CurrentPayloadZip: Require(options, "current-zip"), OutputRoot: Require(options, "output-dir"), - PreviousVersion: Get(options, "previous-version", "0.0.0") ?? "0.0.0", - PreviousDirectory: Get(options, "previous-dir"), Channel: Get(options, "channel", "stable") ?? "stable", - DistributionId: Get(options, "distribution-id"), - RepoBaseUrl: Get(options, "repo-base-url"), - FileMapUrl: Get(options, "file-map-url"), - FileMapSignatureUrl: Get(options, "file-map-signature-url"), - InstallerDirectory: Get(options, "installer-directory"), - InstallerBaseUrl: Get(options, "installer-base-url"))); - - Console.WriteLine($"Generated PLONDS artifacts for {result.Platform}: {result.DistributionId}"); - Console.WriteLine(result.FileMapPath); - } - - private static void RunSign(Dictionary 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 options) - { - var publisher = new PlondsPublisher(); - var results = publisher.Publish(new PlondsPublishOptions( - Version: Require(options, "version"), - AppArtifactsRoot: Require(options, "app-artifacts-root"), - InstallerArtifactsRoot: Require(options, "installer-artifacts-root"), - OutputRoot: Require(options, "output-dir"), - PrivateKeyPath: Require(options, "private-key"), - Channel: Get(options, "channel", "stable") ?? "stable", - BaselineRoot: Get(options, "baseline-root"), - RepoBaseUrl: Get(options, "repo-base-url"), - InstallerBaseUrl: Get(options, "installer-base-url"), - IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload", BaselineVersion: Get(options, "baseline-version"), - BaselineRef: Get(options, "baseline-ref"), - SourceCommit: Get(options, "source-commit"), - IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease, - CommitRangeStart: Get(options, "commit-range-start"), - CommitRangeEnd: Get(options, "commit-range-end"))); + BaselinePayloadZip: Get(options, "baseline-zip"), + LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe", + HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256")); - foreach (var result in results) - { - Console.WriteLine($"{result.Platform}: {result.DistributionId}"); - } + Console.WriteLine($"Built PLONDS delta for {result.Platform}:"); + Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}"); + Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}"); + Console.WriteLine($" ChangedZip: {result.ChangedZipPath}"); + Console.WriteLine($" Manifest: {result.ManifestPath}"); + } + + private static void RunBuildDeltaFromCommits(Dictionary 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 options) @@ -121,56 +96,6 @@ internal static class PlondsCli Console.WriteLine(outputZip); } - private static void RunBuildDelta(Dictionary 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 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 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 ParseOptions(string[] args) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -212,12 +137,8 @@ internal static class PlondsCli private static void PrintUsage() { Console.WriteLine("PLONDS Tool"); + Console.WriteLine(" build-delta --platform

--current-version --current-zip --output-dir

[--channel ] [--baseline-version ] [--baseline-zip ] [--launcher-path ] [--hash-algorithm sha256|md5]"); + Console.WriteLine(" build-delta-from-commits --platform

--current-version --current-zip --output-dir

--channel --baseline-tag --current-tag [--fallback-zip ] [--baseline-version ] [--launcher-path ] [--hash-algorithm sha256|md5]"); Console.WriteLine(" pack-payload --source-dir --output-zip "); - Console.WriteLine(" build-delta --platform --current-version --current-tag --current-zip --output-dir --private-key [--baseline-tag ] [--baseline-version ] [--baseline-zip ] [--is-full-payload] [--static-output-dir ] [--update-base-url ]"); - Console.WriteLine(" build-index --release-tag --version --platform-summaries-dir --output-dir --private-key [--channel ]"); - Console.WriteLine(" build-plonds --release-tag --assets-dir --output-dir --private-key --repository [--s3-base-url ]"); - Console.WriteLine(" sign --manifest --private-key [--output ]"); - Console.WriteLine(" generate --current-version --current-dir --platform --output-dir [--previous-version ] [--previous-dir ]"); - Console.WriteLine(" publish --version --app-artifacts-root --installer-artifacts-root --output-dir --private-key [--baseline-root ]"); } } diff --git a/check_ipc.cs b/check_ipc.cs new file mode 100644 index 0000000..0df1375 --- /dev/null +++ b/check_ipc.cs @@ -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); + } + } +} diff --git a/size_analysis.ps1 b/size_analysis.ps1 new file mode 100644 index 0000000..81dbc06 --- /dev/null +++ b/size_analysis.ps1 @@ -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 } +} diff --git a/size_analysis2.ps1 b/size_analysis2.ps1 new file mode 100644 index 0000000..b21cdf3 --- /dev/null +++ b/size_analysis2.ps1 @@ -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)" +}