Compare commits

...

5 Commits

Author SHA1 Message Date
lincube
5be4537b2c feat.Arknight endfiled 2026-05-30 13:50:13 +08:00
lincube
c5e75244af feat.PLONDS系统会不断地改进 2026-05-30 13:47:15 +08:00
lincube
6a650873bc feat..去除了冗余的字体文件,又修改了PLONDS系统 2026-05-30 11:56:50 +08:00
lincube
d004088601 chabged.进一步清理启动器内的更新逻辑 2026-05-29 22:16:40 +08:00
lincube
a1cc0ee2bf changed.PLONDS启动 2026-05-28 20:07:03 +08:00
146 changed files with 3433 additions and 5451 deletions

View File

@@ -1,278 +0,0 @@
name: PLONDS
concurrency:
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
on:
release:
types:
- published
- prereleased
- edited
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
baseline_tag:
description: 'Optional baseline tag'
required: false
type: string
channel:
description: 'Update channel'
required: false
type: choice
default: stable
options:
- stable
- preview
env:
DOTNET_VERSION: '10.0.x'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release context
shell: bash
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
CHANNEL="preview"
else
CHANNEL="stable"
fi
BASELINE_TAG=""
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
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"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
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 }}
PDC_SIGNING_KEY: ${{ secrets.PDC_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 KEY="${PDC_SIGNING_KEY:-}"; 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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
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')
$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"
}
$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
}
[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
- name: Download payload zips
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
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
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
}
}
- name: Build delta assets
shell: pwsh
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
)
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 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
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"
)
foreach ($path in $required) {
if (-not (Test-Path $path)) {
throw "Missing PLONDS static output: $path"
}
}
}
$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."
}
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
- name: Upload PLONDS assets to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
- name: Persist run metadata
shell: bash
run: |
mkdir -p plonds-run-metadata
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
with:
name: plonds-run-metadata
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

258
.github/workflows/plonds-comparator.yml vendored Normal file
View File

@@ -0,0 +1,258 @@
name: PLONDS Comparator
concurrency:
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
on:
release:
types:
- published
- prereleased
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
baseline_tag:
description: 'Optional baseline tag (auto-detected if omitted)'
required: false
type: string
channel:
description: 'Update channel'
required: false
type: choice
default: stable
options:
- stable
- preview
compare_method:
description: 'Compare method'
required: false
type: choice
default: file-compare
options:
- file-compare
- commit-analyze
hash_algorithm:
description: 'Hash algorithm (file-compare only)'
required: false
type: choice
default: sha256
options:
- sha256
- md5
env:
DOTNET_VERSION: '10.0.x'
jobs:
compare:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- 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
CHANNEL="preview"
else
CHANNEL="stable"
fi
BASELINE_TAG_INPUT=""
COMPARE_METHOD="file-compare"
HASH_ALGORITHM="sha256"
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
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_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
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Resolve baseline
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
BASELINE_TAG=""
BASELINE_VERSION=""
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")"
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
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: bash
run: |
set -euo pipefail
mkdir -p plonds-input
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 [[ -n "$BASELINE_TAG" ]]; then
gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D plonds-input
mv plonds-input/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip
fi
- name: Run build-delta (file-compare)
if: env.COMPARE_METHOD == 'file-compare'
shell: bash
run: |
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
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
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:-$RELEASE_TAG}"
'--current-tag' "$RELEASE_TAG"
'--hash-algorithm' "$HASH_ALGORITHM"
)
if [[ -n "$BASELINE_TAG" ]]; then
ARGS+=(
'--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
)
fi
dotnet "${ARGS[@]}"
- name: Validate output
shell: bash
run: |
set -euo pipefail
if [[ ! -f plonds-output/changed.zip ]]; then
echo "Missing output: changed.zip"
exit 1
fi
if [[ ! -f plonds-output/PLONDS.json ]]; then
echo "Missing output: PLONDS.json"
exit 1
fi
jq -e . plonds-output/PLONDS.json >/dev/null
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
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/compare-method.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
with:
name: plonds-run-metadata
path: |
plonds-run-metadata/tag.txt
plonds-run-metadata/compare-method.txt
if-no-files-found: error
retention-days: 7

View File

@@ -1,4 +1,4 @@
name: DDSS Rollback
name: PLONDS Rollback
on:
workflow_dispatch:
@@ -26,7 +26,7 @@ jobs:
contents: read
concurrency:
group: ddss-rollback-${{ github.event.inputs.channel }}
group: plonds-rollback-${{ github.event.inputs.channel }}
cancel-in-progress: false
steps:
@@ -63,7 +63,7 @@ jobs:
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
- name: Validate rollback target assets
env:
@@ -77,7 +77,7 @@ jobs:
run: |
set -euo pipefail
for name in ddss.json ddss.json.sig; do
for name in plonds.json plonds.json.sig; do
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
@@ -90,10 +90,10 @@ jobs:
set -euo pipefail
mkdir -p rollback-output
pointer_file="rollback-output/ddss-latest.json"
pointer_file="rollback-output/plonds-latest.json"
manifest_url="${S3_BASE_URL}/ddss.json"
sig_url="${S3_BASE_URL}/ddss.json.sig"
manifest_url="${S3_BASE_URL}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.json.sig"
version="${RELEASE_TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -125,22 +125,22 @@ jobs:
run: |
set -euo pipefail
pointer_file="rollback-output/ddss-latest.json"
pointer_file="rollback-output/plonds-latest.json"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" \
--key "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
- name: Print rollback summary
shell: bash
run: |
set -euo pipefail
echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json"
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json"

View File

@@ -1,13 +1,13 @@
name: DDSS
name: PLONDS Publisher
concurrency:
group: ddss-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
group: plonds-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
on:
workflow_run:
workflows:
- PLONDS
- PLONDS Comparator
types:
- completed
workflow_dispatch:
@@ -61,7 +61,7 @@ jobs:
CHANNEL="stable"
fi
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
if [[ -z "$PUBLIC_BASE" ]]; then
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
@@ -80,13 +80,11 @@ jobs:
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_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 KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
@@ -141,7 +139,7 @@ jobs:
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
if [[ "$name" == "plonds.json" || "$name" == "plonds.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
@@ -211,21 +209,21 @@ jobs:
--metadata "sha256=$sha256"
done
- name: Build DDSS manifest
- name: Build PLONDS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p ddss-output
mkdir -p plonds-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-ddss \
build-plonds \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir ddss-output \
--output-dir plonds-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Validate DDSS asset references in Rainyun S3
- name: Validate PLONDS asset references in Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
@@ -236,12 +234,12 @@ jobs:
shell: bash
run: |
set -euo pipefail
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' ddss-output/ddss.json \
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' plonds-output/plonds.json \
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
| sort -u)
if [[ -z "$keys" ]]; then
echo "No S3-backed asset URLs found in ddss.json"
echo "No S3-backed asset URLs found in plonds.json"
exit 1
fi
@@ -252,15 +250,15 @@ jobs:
--key "$key" >/dev/null
done <<< "$keys"
- name: Upload DDSS manifest to release
- name: Upload PLONDS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber
- name: Upload DDSS manifest to Rainyun S3 staging
- name: Upload PLONDS manifest to Rainyun S3 staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
@@ -271,7 +269,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
for file in plonds-output/plonds.json plonds-output/plonds.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
@@ -282,11 +280,11 @@ jobs:
--metadata "sha256=$sha256"
done
- name: Prepare DDSS channel pointer
- name: Prepare PLONDS channel pointer
shell: bash
run: |
set -euo pipefail
pointer_file="ddss-output/ddss-latest.json"
pointer_file="plonds-output/plonds-latest.json"
cat > "$pointer_file" <<'JSON'
{
"schemaVersion": 1,
@@ -301,8 +299,8 @@ jobs:
}
JSON
manifest_url="${S3_BASE_URL}/ddss.json"
sig_url="${S3_BASE_URL}/ddss.json.sig"
manifest_url="${S3_BASE_URL}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.json.sig"
version="${RELEASE_TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -315,7 +313,7 @@ jobs:
jq -e . "$pointer_file" >/dev/null
- name: Atomically publish DDSS channel pointer
- name: Atomically publish PLONDS channel pointer
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
@@ -326,8 +324,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
pointer_file="ddss-output/ddss-latest.json"
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json"
pointer_file="plonds-output/plonds-latest.json"
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-latest.json"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
@@ -336,14 +334,14 @@ jobs:
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" \
--key "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
- name: Verify Rainyun S3 PLONDS output
env:

View File

@@ -1,16 +0,0 @@
# Checklist
- [x] `release.yml` does not invoke Velopack.
- [x] `plonds-build.yml` uploads app payload artifacts and generates PloNDS delta/static outputs.
- [x] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
- [x] CI workflow expects `repo/`, `meta/`, `manifests/`, and `installers/` outputs after a release run.
- [x] Host update source keeps compatibility (`pdc`/`stcn` normalize to active PloNDS source).
- [x] Host can persist PloNDS payload into launcher incoming directory.
- [x] Launcher can apply PloNDS FileMap payload with signature/hash verification.
- [x] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
- [x] Launcher keeps rollback-capable deployments after successful update.
- [x] Manual rollback returns a structured failure when the snapshot source directory is missing.
- [ ] CI run attached proving all release matrix jobs pass.
- [x] N-1 -> N incremental update verified locally on Windows x64.
- [ ] N-1 -> N incremental update verified on Windows x86 and Linux x64.
- [x] Rollback regression tests attached in `LanMountainDesktop.Tests`.

View File

@@ -1,48 +0,0 @@
# PDC Incremental Update Migration
## Goal
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
## Stage 1 (Completed)
- Release workflow removed VeloPack-based release packaging.
- Signed FileMap path was restored as an interim release mechanism.
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
## Stage 2 (Current Implementation Target)
- Use GitHub Actions PloNDS static publishing as the active incremental path.
- Keep `phainon.yml` for future PDCC parity, but do not rely on PDCC for the current release flow.
- Promote PloNDS-distributed FileMap/object-repo as the primary incremental path.
- Keep GitHub Release installers and metadata as parallel distribution.
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
- Check updates in order: NS3/PloNDS static source, GitHub Release PloNDS assets, then GitHub full installer.
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
- Public object URLs come from `S3_PUBLIC_BASE_URL`; do not infer them from `S3_ENDPOINT` and `S3_BUCKET`.
Expected S3 layout:
- `lanmountain/update/repo/sha256/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<platform>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>.json`
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json`
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json.sig`
- `lanmountain/update/installers/<platform>/<version>/*`
## Acceptance
- `release.yml` contains no Velopack steps; PloNDS static publishing is handled by `plonds-build.yml` and `ddss-publish.yml`.
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
- PloNDS metadata + FileMap + object repo are published under `lanmountain/update/`.
- Host can consume the NS3/PloNDS static payload and fallback to GitHub when unavailable.
- Launcher can apply both:
- legacy signed `files.json + update.zip`
- PloNDS FileMap object-repo payload.
- Rollback semantics keep both automatic failure rollback and manual rollback after a successful update.
## Deprecated Notes
- The following interim outputs are compatibility-only (not the long-term primary path):
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`

View File

@@ -1,21 +0,0 @@
# Tasks
- [x] Remove VeloPack packaging from release workflow.
- [x] Keep signed FileMap path as interim compatibility fallback.
- [x] Remove launcher/runtime Velopack branching.
- [x] Add `phainon.yml` for PDCC publish configuration.
- [ ] Add PDCC installation + publish steps in `release.yml` (deferred; active path is GitHub Actions PloNDS static publish).
- [x] Upload app payload artifacts for PloNDS delta generation in release build jobs.
- [x] Publish PloNDS metadata + sha256 object repo to S3 path root `lanmountain/update/`.
- [x] Mirror installers to `lanmountain/update/installers/<platform>/<version>/`.
- [x] Keep update source compatibility (`pdc`/`stcn` normalize to active PloNDS source).
- [x] Add PloNDS static payload model into host update check result.
- [x] Add host download path for PloNDS payload (`plonds-filemap.json` + signature + object repo).
- [x] Add launcher PloNDS FileMap apply path with rollback-compatible semantics.
- [x] Keep old `files.json + update.zip` path behind compatibility fallback.
- [x] Keep rollback deployment directories after successful apply and prune by bounded retention.
- [x] Return structured failure when manual rollback snapshot source is missing.
- [x] Verify static S3 layout, filemap/signature, distribution, latest pointer, and at least one object in CI workflows.
- [x] Add regression tests for PloNDS success rollback, hash-failure auto rollback, missing rollback source, static NS3 manifest, and manifest field mapping.
- [ ] Attach live CI run proving the full release matrix passes.
- [ ] Verify N-1 -> N incremental update on Windows x86 and Linux x64 in release artifacts.

View File

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

View File

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

10
CheckIpcAot/Program.cs Normal file
View File

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

View File

@@ -67,8 +67,7 @@ public partial class App : Application
return;
}
if (context.IsDebugMode && !context.IsPreviewCommand &&
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
if (context.IsDebugMode && !context.IsPreviewCommand)
{
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
new DevDebugWindow().Show();
@@ -76,18 +75,9 @@ public partial class App : Application
return;
}
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
var updateWindow = new UpdateWindow();
updateWindow.Show();
_ = ApplyUpdateEntryHandler.RunAsync(desktop, context, updateWindow);
}
else
{
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
splashWindow.Show();
_ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
}
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
splashWindow.Show();
_ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
base.OnFrameworkInitializationCompleted();
}

View File

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

View File

@@ -12,7 +12,6 @@ internal sealed class CommandContext
[
"launch",
AirAppBrokerCommand,
"apply-update",
"preview-splash",
"preview-error",
"preview-update",
@@ -70,7 +69,6 @@ internal sealed class CommandContext
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
public bool IsMaintenanceCommand =>
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
@@ -118,11 +116,6 @@ internal sealed class CommandContext
return "debug-preview";
}
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
return "apply-update";
}
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
{
return "plugin-install";
@@ -143,7 +136,6 @@ internal sealed class CommandContext
"normal" => "normal",
"restart" => "restart",
"postinstall" => "postinstall",
"apply-update" => "apply-update",
"plugin-install" => "plugin-install",
"debug-preview" => "debug-preview",
_ => null

View File

@@ -5,4 +5,3 @@ global using LanMountainDesktop.Launcher.Ipc;
global using LanMountainDesktop.Launcher.Oobe;
global using LanMountainDesktop.Launcher.Plugins;
global using LanMountainDesktop.Launcher.Startup;
global using LanMountainDesktop.Launcher.Update;

View File

@@ -35,15 +35,14 @@ internal static class Commands
public static async Task<int> RunCliCommandAsync(CommandContext context)
{
var appRoot = ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = UpdateEngineFactory.Create(deploymentLocator);
_ = new DeploymentLocator(appRoot);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
LauncherResult result;
try
{
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
result = ExecuteCore(context, pluginInstaller, pluginUpgrades);
}
catch (Exception ex)
{
@@ -61,16 +60,13 @@ internal static class Commands
return result.Success ? 0 : 1;
}
private static async Task<LauncherResult> ExecuteCoreAsync(
private static LauncherResult ExecuteCore(
CommandContext context,
IUpdateEngine updateEngine,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.Command.ToLowerInvariant())
{
case "update":
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
case "plugin":
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
default:
@@ -84,33 +80,6 @@ internal static class Commands
}
}
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, IUpdateEngine updateEngine)
{
return context.SubCommand.ToLowerInvariant() switch
{
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
_ => new LauncherResult
{
Success = false,
Stage = "update",
Code = "unsupported_subcommand",
Message = $"Unsupported update sub-command '{context.SubCommand}'."
}
};
}
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, IUpdateEngine updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand(
CommandContext context,
PluginInstallerService pluginInstaller,

View File

@@ -6,6 +6,12 @@ using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Ipc;
internal interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
{
private const int LengthPrefixSize = 4;

View File

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

View File

@@ -1,78 +0,0 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Shell;
internal static class ApplyUpdateGuiFlow
{
public static async Task RunAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window)
{
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = UpdateEngineFactory.Create(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
var success = true;
string? errorMessage = null;
try
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
success = false;
errorMessage = updateResult.Message;
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60));
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
{
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
}
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
catch (Exception ex)
{
success = false;
errorMessage = ex.Message;
Logger.Error("Apply-update flow failed.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{
Success = success,
Stage = "apply-update",
Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource
}
}).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
}

View File

@@ -31,15 +31,6 @@ internal static class LaunchEntryHandler
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
}
internal static class ApplyUpdateEntryHandler
{
public static Task RunAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window) =>
ApplyUpdateGuiFlow.RunAsync(desktop, context, window);
}
internal static class AirAppBrokerEntryHandler
{
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)

View File

@@ -13,7 +13,6 @@ internal sealed class LauncherOrchestrator
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
private readonly IUpdateEngine _updateEngine;
private readonly StartupAttemptRegistry _startupAttemptRegistry;
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
private readonly DataLocationResolver _dataLocationResolver;
@@ -24,7 +23,6 @@ internal sealed class LauncherOrchestrator
CommandContext context,
DeploymentLocator deploymentLocator,
OobeStateService oobeStateService,
IUpdateEngine updateEngine,
StartupAttemptRegistry startupAttemptRegistry,
LauncherCoordinatorIpcServer? coordinatorIpcServer = null,
LaunchPipeline? pipeline = null)
@@ -32,7 +30,6 @@ internal sealed class LauncherOrchestrator
_context = context;
_deploymentLocator = deploymentLocator;
_oobeStateService = oobeStateService;
_updateEngine = updateEngine;
_startupAttemptRegistry = startupAttemptRegistry;
_coordinatorIpcServer = coordinatorIpcServer;
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
@@ -45,7 +42,6 @@ internal sealed class LauncherOrchestrator
[
new CleanupDeploymentsPhase(),
new ExistingHostProbePhase(),
new ApplyPendingUpdatePhase(),
new OobeGatePhase(),
new LaunchHostPhase(),
new MonitorStartupPhase()
@@ -217,7 +213,6 @@ internal sealed class LauncherOrchestrator
CommandContext = _context,
DeploymentLocator = _deploymentLocator,
OobeStateService = _oobeStateService,
UpdateEngine = _updateEngine,
StartupAttemptRegistry = _startupAttemptRegistry,
CoordinatorIpcServer = _coordinatorIpcServer,
DataLocationResolver = _dataLocationResolver,

View File

@@ -22,12 +22,10 @@ internal static class LauncherServiceRegistration
services.AddSingleton(new DeploymentLocator(appRoot));
services.AddSingleton(sp => new OobeStateService(appRoot));
services.AddSingleton(sp => new DataLocationResolver(appRoot));
services.AddSingleton(sp => UpdateEngineFactory.Create(sp.GetRequiredService<DeploymentLocator>()));
services.AddSingleton<HostLaunchService>();
services.AddSingleton<StartupAttemptRegistry>();
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
services.AddSingleton<ILaunchPhase, ExistingHostProbePhase>();
services.AddSingleton<ILaunchPhase, ApplyPendingUpdatePhase>();
services.AddSingleton<ILaunchPhase, OobeGatePhase>();
services.AddSingleton<ILaunchPhase, LaunchHostPhase>();
services.AddSingleton<ILaunchPhase, MonitorStartupPhase>();
@@ -47,7 +45,6 @@ internal static class LauncherServiceRegistration
context,
services.GetRequiredService<DeploymentLocator>(),
services.GetRequiredService<OobeStateService>(),
services.GetRequiredService<IUpdateEngine>(),
startupAttemptRegistry,
coordinatorServer,
services.GetRequiredService<LaunchPipeline>());

View File

@@ -27,7 +27,6 @@ internal sealed class LaunchContext
public required CommandContext CommandContext { get; init; }
public required DeploymentLocator DeploymentLocator { get; init; }
public required OobeStateService OobeStateService { get; init; }
public required IUpdateEngine UpdateEngine { get; init; }
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
public required DataLocationResolver DataLocationResolver { get; init; }

View File

@@ -1,27 +0,0 @@
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class ApplyPendingUpdatePhase : ILaunchPhase
{
public string Name => nameof(ApplyPendingUpdatePhase);
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
context.Reporter.Report("update", "Checking updates...");
var updateResult = await context.UpdateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'.");
context.Reporter.Report("update", "Update failed, launching existing version...");
try
{
context.UpdateEngine.CleanupIncomingArtifacts();
}
catch (Exception ex)
{
Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}");
}
}
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
}
}

View File

@@ -1,18 +0,0 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal interface IUpdateEngine
{
LauncherResult CheckPendingUpdate();
Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken);
Task<LauncherResult> ApplyPendingUpdateAsync();
LauncherResult RollbackLatest();
void CleanupDestroyedDeployments();
void CleanupIncomingArtifacts();
}

View File

@@ -1,9 +0,0 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
public interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}

View File

@@ -1,287 +0,0 @@
using System.IO.Compression;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class LegacyUpdateApplier(
DeploymentLocator deploymentLocator,
UpdateEnginePaths paths,
UpdateSignatureVerifier signatureVerifier,
IUpdateProgressReporter progressReporter,
UpdateSnapshotStore snapshotStore,
InstallCheckpointStore checkpointStore,
DeploymentActivator deploymentActivator,
IncomingArtifactsCleaner incomingCleaner)
{
public async Task<LauncherResult> ApplyAsync()
{
if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.ArchivePath))
{
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "noop",
Message = "No update payload found."
};
}
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0));
var verifyResult = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName);
if (!verifyResult.Success)
{
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message);
}
var fileMapText = await File.ReadAllTextAsync(paths.FileMapPath).ConfigureAwait(false);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null || fileMap.Files.Count == 0)
{
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false));
return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No update file entries were found.");
}
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
var currentVersion = deploymentLocator.GetCurrentVersion();
if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
!string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
{
return UpdateEngineResults.Failed(
"update.apply",
"version_mismatch",
$"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
}
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
var existingCheckpoint = checkpointStore.Load();
var canResume = existingCheckpoint is not null
&& string.Equals(existingCheckpoint.SourceVersion, currentVersion, StringComparison.OrdinalIgnoreCase)
&& string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase)
&& string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase)
&& Directory.Exists(existingCheckpoint.TargetDirectory)
&& File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial"));
if (existingCheckpoint is not null && !canResume)
{
return UpdateEngineResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload.");
}
var targetDeployment = canResume
? existingCheckpoint!.TargetDirectory
: deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
var snapshot = BuildSnapshot(canResume, existingCheckpoint, currentVersion, targetVersion, currentDeployment, targetDeployment);
var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId);
var checkpoint = canResume
? existingCheckpoint!
: BuildCheckpoint(snapshot, currentVersion, targetVersion, currentDeployment, targetDeployment);
try
{
snapshotStore.Save(snapshotPath, snapshot);
PrepareExtractRoot();
ZipFile.ExtractToDirectory(paths.ArchivePath, paths.ExtractRoot, overwriteFiles: true);
if (!canResume)
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count));
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
}
checkpointStore.Save(checkpoint);
ApplyFiles(fileMap, currentDeployment!, targetDeployment, checkpoint);
VerifyFiles(fileMap, targetDeployment, checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count));
deploymentActivator.Activate(currentDeployment!, targetDeployment);
snapshot.Status = "applied";
snapshotStore.Save(snapshotPath, snapshot);
incomingCleaner.Cleanup();
deploymentActivator.RetainDeploymentsForRollback();
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count));
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false));
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "ok",
Message = $"Updated to {targetVersion}.",
CurrentVersion = currentVersion,
TargetVersion = targetVersion
};
}
catch (Exception ex)
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
var rollbackResult = deploymentActivator.TryRollbackOnFailure(snapshot);
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
snapshotStore.Save(snapshotPath, snapshot);
var errorMessage = rollbackResult.Success
? ex.Message
: $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success));
return new LauncherResult
{
Success = false,
Stage = "update.apply",
Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
Message = rollbackResult.Success
? "Failed to apply update. Rolled back to previous version."
: "Failed to apply update and rollback failed.",
ErrorMessage = errorMessage,
CurrentVersion = currentVersion,
RolledBackTo = rollbackResult.Success ? currentVersion : null
};
}
finally
{
checkpointStore.Delete();
TryDeleteExtractRoot();
}
}
private void ApplyFiles(SignedFileMap fileMap, string currentDeployment, string targetDeployment, InstallCheckpoint checkpoint)
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, checkpoint.AppliedCount, fileMap.Files.Count));
for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileMap.Files.Count; fileIndex++)
{
var file = fileMap.Files[fileIndex];
ApplyFileEntry(file, currentDeployment, targetDeployment);
checkpoint.AppliedCount = fileIndex + 1;
checkpointStore.Save(checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (checkpoint.AppliedCount * 30 / fileMap.Files.Count), file.Path, checkpoint.AppliedCount, fileMap.Files.Count));
}
}
private void VerifyFiles(SignedFileMap fileMap, string targetDeployment, InstallCheckpoint checkpoint)
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, checkpoint.VerifiedCount, fileMap.Files.Count));
for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileMap.Files.Count; verifyIndex++)
{
var file = fileMap.Files[verifyIndex];
if (NeedsVerification(file))
{
var fullPath = Path.Combine(targetDeployment, file.Path);
var actualHash = UpdateHash.ComputeSha256Hex(fullPath);
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
}
}
checkpoint.VerifiedCount = verifyIndex + 1;
checkpointStore.Save(checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileMap.Files.Count), file.Path, checkpoint.VerifiedCount, fileMap.Files.Count));
}
}
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment)
{
var normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path);
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
}
var targetPath = Path.Combine(targetDeployment, normalizedPath);
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
var targetDir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDir))
{
Directory.CreateDirectory(targetDir);
}
if (string.Equals(file.Action, "reuse", StringComparison.OrdinalIgnoreCase))
{
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
UpdatePathGuard.EnsurePathWithinRoot(sourcePath, currentDeployment);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
}
File.Copy(sourcePath, targetPath, overwrite: true);
return;
}
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : UpdatePathGuard.NormalizeRelativePath(file.ArchivePath);
var extractedPath = Path.Combine(paths.ExtractRoot, archiveRelative);
UpdatePathGuard.EnsurePathWithinRoot(extractedPath, paths.ExtractRoot);
if (!File.Exists(extractedPath))
{
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
}
File.Copy(extractedPath, targetPath, overwrite: true);
}
private void PrepareExtractRoot()
{
if (Directory.Exists(paths.ExtractRoot))
{
Directory.Delete(paths.ExtractRoot, true);
}
Directory.CreateDirectory(paths.ExtractRoot);
}
private void TryDeleteExtractRoot()
{
try
{
if (Directory.Exists(paths.ExtractRoot))
{
Directory.Delete(paths.ExtractRoot, true);
}
}
catch
{
}
}
private static SnapshotMetadata BuildSnapshot(
bool canResume,
InstallCheckpoint? existingCheckpoint,
string currentVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment) =>
new()
{
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
SourceVersion = currentVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = currentDeployment ?? string.Empty,
TargetDirectory = targetDeployment,
Status = "pending"
};
private static InstallCheckpoint BuildCheckpoint(
SnapshotMetadata snapshot,
string currentVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment) =>
new()
{
SnapshotId = snapshot.SnapshotId,
SourceVersion = currentVersion,
TargetVersion = targetVersion,
SourceDirectory = currentDeployment,
TargetDirectory = targetDeployment,
IsInitialDeployment = false
};
private static bool NeedsVerification(UpdateFileEntry file)
{
return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(file.Sha256);
}
}

View File

@@ -1,9 +0,0 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter
{
public void ReportProgress(InstallProgressReport report) { }
public void ReportComplete(InstallCompleteReport report) { }
}

View File

@@ -1,116 +0,0 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class PendingUpdateDetector(
DeploymentLocator deploymentLocator,
UpdateEnginePaths paths,
UpdateSignatureVerifier signatureVerifier)
{
public LauncherResult CheckPendingUpdate()
{
if (File.Exists(paths.PlondsFileMapPath) && File.Exists(paths.PlondsSignaturePath))
{
var pdcFileMapText = File.ReadAllText(paths.PlondsFileMapPath);
var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap);
if (pdcFileMap is null)
{
return UpdateEngineResults.Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid.");
}
var pdcVerified = signatureVerifier.Verify(
paths.PlondsFileMapPath,
paths.PlondsSignaturePath,
UpdateEnginePaths.PlondsSignatureFileName);
if (!pdcVerified.Success)
{
return UpdateEngineResults.Failed("update.check", "signature_failed", pdcVerified.Message);
}
var pdcMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath);
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "available",
Message = "Pending PLONDS update is available.",
CurrentVersion = deploymentLocator.GetCurrentVersion(),
TargetVersion = PlondsManifestParser.ResolveTargetVersion(pdcFileMap, pdcMetadata)
};
}
if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.ArchivePath))
{
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "noop",
Message = "No pending update."
};
}
var fileMapText = File.ReadAllText(paths.FileMapPath);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null)
{
return UpdateEngineResults.Failed("update.check", "invalid_manifest", "files.json is invalid.");
}
var verified = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName);
if (!verified.Success)
{
return UpdateEngineResults.Failed("update.check", "signature_failed", verified.Message);
}
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "available",
Message = "Pending update is available.",
CurrentVersion = deploymentLocator.GetCurrentVersion(),
TargetVersion = fileMap.ToVersion
};
}
public LauncherResult ValidateIncomingState()
{
if (File.Exists(paths.ApplyLockPath))
{
return UpdateEngineResults.Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress.");
}
if (!File.Exists(paths.DeploymentLockPath))
{
return UpdateEngineResults.Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update.");
}
var hasPlondsMap = File.Exists(paths.PlondsFileMapPath);
var hasLegacyMap = File.Exists(paths.FileMapPath);
if (hasPlondsMap && !File.Exists(paths.DownloadMarkerPath))
{
return UpdateEngineResults.Failed("update.apply", "staging_incomplete", "Download marker is missing for pending PLONDS update.");
}
if (!hasPlondsMap && !hasLegacyMap)
{
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "noop",
Message = "No update payload found."
};
}
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "ok",
Message = "Incoming update state validated."
};
}
}

View File

@@ -1,119 +0,0 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class UpdateEngineFacade : IUpdateEngine
{
private readonly UpdateEnginePaths _paths;
private readonly PendingUpdateDetector _pendingUpdateDetector;
private readonly LegacyUpdateApplier _legacyUpdateApplier;
private readonly PlondsUpdateApplier _plondsUpdateApplier;
private readonly RollbackStrategy _rollbackStrategy;
private readonly DeploymentActivator _deploymentActivator;
private readonly IncomingArtifactsCleaner _incomingArtifactsCleaner;
public UpdateEngineFacade(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
{
var reporter = progressReporter ?? new NullUpdateProgressReporter();
_paths = new UpdateEnginePaths(deploymentLocator.GetAppRoot());
var signatureVerifier = new UpdateSignatureVerifier(_paths);
var snapshotStore = new UpdateSnapshotStore(_paths);
var checkpointStore = new InstallCheckpointStore(_paths);
_deploymentActivator = new DeploymentActivator(deploymentLocator);
_incomingArtifactsCleaner = new IncomingArtifactsCleaner(_paths);
_pendingUpdateDetector = new PendingUpdateDetector(deploymentLocator, _paths, signatureVerifier);
_legacyUpdateApplier = new LegacyUpdateApplier(
deploymentLocator,
_paths,
signatureVerifier,
reporter,
snapshotStore,
checkpointStore,
_deploymentActivator,
_incomingArtifactsCleaner);
_plondsUpdateApplier = new PlondsUpdateApplier(
deploymentLocator,
_paths,
signatureVerifier,
reporter,
snapshotStore,
checkpointStore,
_deploymentActivator,
_incomingArtifactsCleaner,
new PlondsPayloadResolver(_paths));
_rollbackStrategy = new RollbackStrategy(deploymentLocator, snapshotStore, _deploymentActivator);
}
public LauncherResult CheckPendingUpdate() => _pendingUpdateDetector.CheckPendingUpdate();
public Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
{
_ = manifestUrl;
_ = signatureUrl;
_ = archiveUrl;
_ = cancellationToken;
return Task.FromResult(new LauncherResult
{
Success = false,
Stage = "update.download",
Code = "host_managed_only",
Message = "Launcher no longer performs network downloads. Host must download update payload into incoming directory first."
});
}
public async Task<LauncherResult> ApplyPendingUpdateAsync()
{
Directory.CreateDirectory(_paths.IncomingRoot);
Directory.CreateDirectory(_paths.SnapshotsRoot);
var stateValidation = _pendingUpdateDetector.ValidateIncomingState();
if (!stateValidation.Success || stateValidation.Code == "noop")
{
return stateValidation;
}
try
{
File.WriteAllText(_paths.ApplyLockPath, DateTimeOffset.UtcNow.ToString("O"));
}
catch (Exception ex)
{
return UpdateEngineResults.Failed("update.apply", "lock_conflict", $"Failed to acquire apply lock: {ex.Message}");
}
try
{
if (_paths.HasPlondsPayload)
{
return await _plondsUpdateApplier.ApplyAsync().ConfigureAwait(false);
}
return await _legacyUpdateApplier.ApplyAsync().ConfigureAwait(false);
}
finally
{
TryDeleteApplyLock();
}
}
public LauncherResult RollbackLatest() => _rollbackStrategy.RollbackLatest();
public void CleanupDestroyedDeployments() => _deploymentActivator.RetainDeploymentsForRollback();
public void CleanupIncomingArtifacts() => _incomingArtifactsCleaner.Cleanup();
private void TryDeleteApplyLock()
{
try
{
if (File.Exists(_paths.ApplyLockPath))
{
File.Delete(_paths.ApplyLockPath);
}
}
catch
{
}
}
}

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.Launcher.Update;
internal static class UpdateEngineFactory
{
public static IUpdateEngine Create(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) =>
new UpdateEngineFacade(deploymentLocator, progressReporter);
}

View File

@@ -1,18 +0,0 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal static class UpdateEngineResults
{
public static LauncherResult Failed(string stage, string code, string message)
{
return new LauncherResult
{
Success = false,
Stage = stage,
Code = code,
Message = message,
ErrorMessage = message
};
}
}

View File

@@ -6,7 +6,7 @@ using LanMountainDesktop.Launcher.Resources;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 更新进度窗口 - 用于 apply-update 命令模式显示更新/插件升级进度
/// 更新进度窗口 - 用于预览模式显示更新/插件升级进度
/// </summary>
public partial class UpdateWindow : Window
{

View File

@@ -15,7 +15,7 @@ public sealed record UpdateManifest(
IReadOnlyList<UpdateMirrorAsset>? InstallerMirrors,
IReadOnlyDictionary<string, string> Metadata)
{
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds;
public long EstimatedDeltaBytes
{

View File

@@ -22,7 +22,6 @@ public enum UpdatePhase
public enum UpdatePayloadKind
{
DeltaPlonds,
DeltaLegacy,
FullInstaller
}

View File

@@ -9,7 +9,7 @@ public sealed class CommandContextTests
{
{ [], "normal" },
{ ["preview-oobe"], "debug-preview" },
{ ["apply-update"], "apply-update" },
{ ["apply-update"], "normal" },
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
};

View File

@@ -0,0 +1,7 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;
global using LanMountainDesktop.Launcher.Oobe;
global using LanMountainDesktop.Launcher.Plugins;
global using LanMountainDesktop.Launcher.Startup;

View File

@@ -0,0 +1,69 @@
using LanMountainDesktop.Launcher.Startup;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class HostStartupMonitorTests
{
[Fact]
public void InitialIpcConnectUsesStagedBackoff()
{
var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Startup", "HostStartupMonitor.cs");
Assert.Contains("StartupTimeoutPolicy.InitialIpcConnectTimeout", source);
Assert.Contains("TimeSpan.FromMilliseconds(3000)", source);
Assert.Contains("TimeSpan.FromMilliseconds(5000)", source);
Assert.Contains("TryConnectWithBackoffAsync", source);
}
[Fact]
public void RefreshShellStatus_UsesStartupSuccessTrackerForSuccess()
{
var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Startup", "HostStartupMonitor.cs");
Assert.Contains("SuccessTracker.TryResolve(shellStatus, out var successState)", source);
var refreshBlock = source[
source.IndexOf("RefreshShellStatusAsync", StringComparison.Ordinal) ..
source.IndexOf("var connected = await PublicIpcConnection.TryConnectWithBackoffAsync", StringComparison.Ordinal)];
Assert.DoesNotContain("return new StartupSuccessState", refreshBlock);
Assert.DoesNotContain("successState = new StartupSuccessState", refreshBlock);
}
[Fact]
public void BuildDelayedLoadingState_AddsSoftTimeoutItem()
{
var loadingState = new LoadingStateMessage
{
ActiveItems = [],
OverallProgressPercent = 0,
TotalCount = 0
};
var delayed = HostStartupMonitor.BuildDelayedLoadingState(
loadingState,
"Still starting",
"Host is still warming up.",
DateTimeOffset.UtcNow);
Assert.Equal("Still starting", delayed.Message);
Assert.Contains(delayed.ActiveItems, item => item.Id == "launcher-soft-timeout");
}
private static string ReadRepositoryFile(params string[] pathParts)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
{
directory = directory.Parent;
}
if (directory is null)
{
throw new DirectoryNotFoundException("Unable to locate repository root.");
}
return File.ReadAllText(Path.Combine([directory.FullName, .. pathParts]));
}
}

View File

@@ -33,8 +33,7 @@ public sealed class LauncherArchitectureTests
{
var guardedFiles = new[]
{
Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs"),
Path.Combine(LauncherProjectRoot, "Shell", "ApplyUpdateGuiFlow.cs")
Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs")
}.Concat(Directory.EnumerateFiles(
Path.Combine(LauncherProjectRoot, "Shell", "EntryHandlers"),
"*.cs",
@@ -49,9 +48,8 @@ public sealed class LauncherArchitectureTests
}
[Fact]
public void LauncherFacadeAndCompositionRootStayThin()
public void LauncherCompositionRootStaysThin()
{
AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Update", "UpdateEngineFacade.cs"), 140);
AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Shell", "LauncherCompositionRoot.cs"), 80);
}

View File

@@ -4,4 +4,3 @@ global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;
global using LanMountainDesktop.Launcher.Oobe;
global using LanMountainDesktop.Launcher.Startup;
global using LanMountainDesktop.Launcher.Update;

View File

@@ -134,13 +134,13 @@ public sealed class PendingPluginUpgradeServiceTests : IDisposable
return packagePath;
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
private static LanMountainDesktop.PluginSdk.PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entry = archive.GetEntry(PluginSdkInfo.ManifestFileName)
?? throw new InvalidOperationException("Missing plugin manifest.");
using var stream = entry.Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}");
return LanMountainDesktop.PluginSdk.PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}");
}
public void Dispose()

View File

@@ -1,250 +0,0 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class PendingUpdateDetectorTests : IDisposable
{
private readonly TempLauncherRoot _root = new();
[Fact]
public void ValidateIncomingState_WhenNoPayloadButDeploymentLockExists_ReturnsNoop()
{
_root.WriteDeploymentLock();
var detector = new PendingUpdateDetector(
new DeploymentLocator(_root.AppRoot),
_root.Paths,
new UpdateSignatureVerifier(_root.Paths));
var result = detector.ValidateIncomingState();
Assert.True(result.Success);
Assert.Equal("noop", result.Code);
}
public void Dispose() => _root.Dispose();
}
public sealed class UpdateSignatureVerifierTests : IDisposable
{
private readonly TempLauncherRoot _root = new();
[Fact]
public void Verify_WhenSignatureIsMissing_ReturnsStructuredFailure()
{
var payload = Path.Combine(_root.Paths.IncomingRoot, "files.json");
Directory.CreateDirectory(_root.Paths.IncomingRoot);
File.WriteAllText(payload, "{}");
var result = new UpdateSignatureVerifier(_root.Paths)
.Verify(payload, Path.Combine(_root.Paths.IncomingRoot, "files.json.sig"), "files.json.sig");
Assert.False(result.Success);
Assert.Equal("Missing files.json.sig.", result.Message);
}
public void Dispose() => _root.Dispose();
}
public sealed class IncomingArtifactsCleanerTests : IDisposable
{
private readonly TempLauncherRoot _root = new();
[Fact]
public void Cleanup_RemovesLegacyPlondsAndCheckpointArtifacts()
{
Directory.CreateDirectory(_root.Paths.PlondsObjectsRoot);
foreach (var path in new[]
{
_root.Paths.FileMapPath,
_root.Paths.SignaturePath,
_root.Paths.ArchivePath,
_root.Paths.PlondsFileMapPath,
_root.Paths.PlondsSignaturePath,
_root.Paths.PlondsUpdateMetadataPath,
_root.Paths.InstallCheckpointPath,
Path.Combine(_root.Paths.PlondsObjectsRoot, "payload")
})
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "x");
}
new IncomingArtifactsCleaner(_root.Paths).Cleanup();
Assert.False(File.Exists(_root.Paths.FileMapPath));
Assert.False(File.Exists(_root.Paths.InstallCheckpointPath));
Assert.False(Directory.Exists(_root.Paths.PlondsObjectsRoot));
}
public void Dispose() => _root.Dispose();
}
public sealed class DeploymentActivatorTests : IDisposable
{
private readonly TempLauncherRoot _root = new();
[Fact]
public void Activate_MovesCurrentMarkerAndMarksPreviousDestroy()
{
var from = Path.Combine(_root.AppRoot, "app-1");
var to = Path.Combine(_root.AppRoot, "app-2");
Directory.CreateDirectory(from);
Directory.CreateDirectory(to);
File.WriteAllText(Path.Combine(from, ".current"), string.Empty);
File.WriteAllText(Path.Combine(to, ".partial"), string.Empty);
new DeploymentActivator(new DeploymentLocator(_root.AppRoot)).Activate(from, to);
Assert.False(File.Exists(Path.Combine(from, ".current")));
Assert.True(File.Exists(Path.Combine(from, ".destroy")));
Assert.True(File.Exists(Path.Combine(to, ".current")));
Assert.False(File.Exists(Path.Combine(to, ".partial")));
}
public void Dispose() => _root.Dispose();
}
public sealed class RollbackStrategyTests : IDisposable
{
private readonly TempLauncherRoot _root = new();
[Fact]
public void RollbackLatest_WhenNoSnapshotsExist_ReturnsNoSnapshot()
{
var snapshotStore = new UpdateSnapshotStore(_root.Paths);
var activator = new DeploymentActivator(new DeploymentLocator(_root.AppRoot));
var result = new RollbackStrategy(new DeploymentLocator(_root.AppRoot), snapshotStore, activator)
.RollbackLatest();
Assert.False(result.Success);
Assert.Equal("no_snapshot", result.Code);
}
public void Dispose() => _root.Dispose();
}
public sealed class PlondsUpdateApplierTests
{
[Fact]
public void ManifestParser_ReadsObjectComponentFiles()
{
var map = new PlondsFileMap();
var entries = PlondsManifestParser.CollectFileEntries(map);
PlondsManifestParser.PopulateFromRawJson(
"""
{
"toVersion": "2.0.0",
"components": {
"desktop": {
"files": {
"LanMountainDesktop.exe": {
"archiveSha512": "abcd",
"archivePath": "objects/ab/cd"
}
}
}
}
}
""",
map,
entries);
Assert.Equal("2.0.0", PlondsManifestParser.ResolveTargetVersion(map, null));
var entry = Assert.Single(entries);
Assert.Equal("LanMountainDesktop.exe", entry.Path);
Assert.Equal("desktop", entry.Metadata["component"]);
}
}
public sealed class LegacyUpdateApplierTests : IDisposable
{
private readonly TempLauncherRoot _root = new();
[Fact]
public async Task ApplyAsync_WhenSignatureIsMissing_ReturnsSignatureFailure()
{
_root.WriteDeploymentLock();
Directory.CreateDirectory(_root.Paths.IncomingRoot);
File.WriteAllText(_root.Paths.FileMapPath, JsonSerializer.Serialize(new SignedFileMap
{
FromVersion = "1.0.0",
ToVersion = "2.0.0",
Files = [new UpdateFileEntry { Path = "state.txt" }]
}, AppJsonContext.Default.SignedFileMap));
using (var archive = ZipFile.Open(_root.Paths.ArchivePath, ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("state.txt");
await using var stream = entry.Open();
await stream.WriteAsync(Encoding.UTF8.GetBytes("state"));
}
var applier = CreateLegacyApplier();
var result = await applier.ApplyAsync();
Assert.False(result.Success);
Assert.Equal("signature_failed", result.Code);
}
public void Dispose() => _root.Dispose();
private LegacyUpdateApplier CreateLegacyApplier()
{
var locator = new DeploymentLocator(_root.AppRoot);
var snapshotStore = new UpdateSnapshotStore(_root.Paths);
var checkpointStore = new InstallCheckpointStore(_root.Paths);
var activator = new DeploymentActivator(locator);
var cleaner = new IncomingArtifactsCleaner(_root.Paths);
return new LegacyUpdateApplier(
locator,
_root.Paths,
new UpdateSignatureVerifier(_root.Paths),
new NullUpdateProgressReporter(),
snapshotStore,
checkpointStore,
activator,
cleaner);
}
}
internal sealed class TempLauncherRoot : IDisposable
{
public TempLauncherRoot()
{
AppRoot = Path.Combine(Path.GetTempPath(), "lmd-launcher-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(AppRoot);
Paths = new UpdateEnginePaths(AppRoot);
Directory.CreateDirectory(Paths.IncomingRoot);
}
public string AppRoot { get; }
public UpdateEnginePaths Paths { get; }
public void WriteDeploymentLock()
{
Directory.CreateDirectory(Path.GetDirectoryName(Paths.DeploymentLockPath)!);
File.WriteAllText(Paths.DeploymentLockPath, string.Empty);
}
public void Dispose()
{
try
{
if (Directory.Exists(AppRoot))
{
Directory.Delete(AppRoot, true);
}
}
catch
{
}
}
}

View File

@@ -1,612 +0,0 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Update;
using LanMountainDesktop.Shared.Contracts.Update;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class UpdateEngineRollbackRegressionTests : IDisposable
{
private readonly UpdateTestDirectory _directory = new();
[Fact]
public async Task ApplyPlondsUpdate_KeepsPreviousDeploymentForManualRollback()
{
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
var newState = Encoding.UTF8.GetBytes("new-state");
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.True(result.Success, result.ErrorMessage);
Assert.True(Directory.Exists(current));
Assert.False(File.Exists(Path.Combine(current, ".current")));
var rollback = service.RollbackLatest();
Assert.True(rollback.Success, rollback.ErrorMessage);
Assert.Equal("1.0.0", rollback.RolledBackTo);
Assert.True(File.Exists(Path.Combine(current, ".current")));
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
}
[Fact]
public async Task ApplyPlondsUpdate_WhenObjectHashMismatches_RollsBackToPreviousDeployment()
{
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
var newState = Encoding.UTF8.GetBytes("new-state");
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64));
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.False(result.Success);
Assert.Equal("apply_failed", result.Code);
Assert.Equal("1.0.0", result.RolledBackTo);
Assert.True(File.Exists(Path.Combine(current, ".current")));
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
Assert.Empty(Directory.GetDirectories(_directory.AppRoot, "app-1.1.0-*"));
}
[Fact]
public void RollbackLatest_WhenSnapshotSourceDirectoryIsMissing_ReturnsStructuredFailure()
{
_directory.CreateDeployment("1.1.0", "new-state", isCurrent: true);
_directory.WriteSnapshot(
sourceVersion: "1.0.0",
sourceDirectory: Path.Combine(_directory.AppRoot, "app-1.0.0-0"),
targetVersion: "1.1.0",
targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0"));
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
var result = service.RollbackLatest();
Assert.False(result.Success);
Assert.Equal("source_missing", result.Code);
Assert.Contains("app-1.0.0-0", result.ErrorMessage);
}
[Fact]
public async Task ApplyPlondsUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure()
{
_directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
var newState = Encoding.UTF8.GetBytes("new-state");
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.False(result.Success);
Assert.Equal("resume_state_invalid", result.Code);
}
[Fact]
public async Task ApplyLegacyUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure()
{
_directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.False(result.Success);
Assert.Equal("resume_state_invalid", result.Code);
}
[Fact]
public async Task ApplyPlondsUpdate_WhenCheckpointIsValid_ResumesAndSucceeds()
{
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
var newState = Encoding.UTF8.GetBytes("new-state");
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
_directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0");
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal("1.1.0", result.TargetVersion);
Assert.False(File.Exists(Path.Combine(current, ".current")));
var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0");
Assert.True(File.Exists(Path.Combine(resumedTarget, ".current")));
Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt")));
Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot)));
}
[Fact]
public async Task ApplyLegacyUpdate_WhenCheckpointIsValid_ResumesAndSucceeds()
{
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
_directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0");
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
var result = await service.ApplyPendingUpdateAsync();
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal("1.1.0", result.TargetVersion);
Assert.False(File.Exists(Path.Combine(current, ".current")));
var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0");
Assert.True(File.Exists(Path.Combine(resumedTarget, ".current")));
Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt")));
Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot)));
}
public void Dispose() => _directory.Dispose();
private static string Sha256Hex(byte[] bytes)
{
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private sealed class UpdateTestDirectory : IDisposable
{
private readonly string _root;
private readonly RSA _rsa = RSA.Create(2048);
public UpdateTestDirectory()
{
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.UpdateRegression", Guid.NewGuid().ToString("N"));
AppRoot = Path.Combine(_root, "app-root");
Directory.CreateDirectory(AppRoot);
var resolver = new DataLocationResolver(AppRoot);
LauncherRoot = resolver.ResolveLauncherDataPath();
IncomingRoot = Path.Combine(LauncherRoot, "update", "incoming");
SnapshotsRoot = Path.Combine(LauncherRoot, "snapshots");
Directory.CreateDirectory(Path.Combine(LauncherRoot, "update"));
File.WriteAllText(Path.Combine(LauncherRoot, "update", "public-key.pem"), _rsa.ExportSubjectPublicKeyInfoPem());
}
public string AppRoot { get; }
private string LauncherRoot { get; }
private string IncomingRoot { get; }
private string SnapshotsRoot { get; }
public string CreateDeployment(string version, string state, bool isCurrent)
{
var deployment = Path.Combine(AppRoot, $"app-{version}-0");
Directory.CreateDirectory(deployment);
File.WriteAllText(Path.Combine(deployment, ExecutableName), $"exe-{version}");
File.WriteAllText(Path.Combine(deployment, "state.txt"), state);
if (isCurrent)
{
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
}
return deployment;
}
public void StagePlondsUpdate(string fromVersion, string toVersion, byte[] statePayload, string expectedStateSha256)
{
Directory.CreateDirectory(IncomingRoot);
var objectsRoot = Path.Combine(IncomingRoot, "objects");
Directory.CreateDirectory(objectsRoot);
var objectHash = Convert.ToHexString(SHA256.HashData(statePayload)).ToLowerInvariant();
File.WriteAllBytes(Path.Combine(objectsRoot, objectHash), statePayload);
var currentExecutable = Path.Combine(AppRoot, $"app-{fromVersion}-0", ExecutableName);
var fileMap = new PlondsFileMap
{
DistributionId = $"stable-{PlondsStaticUpdateService.ResolveCurrentPlatform()}-{toVersion}",
FromVersion = fromVersion,
ToVersion = toVersion,
Platform = PlondsStaticUpdateService.ResolveCurrentPlatform(),
Files =
[
new PlondsFileEntry
{
Path = ExecutableName,
Action = "reuse",
Sha256 = Sha256File(currentExecutable)
},
new PlondsFileEntry
{
Path = "state.txt",
Action = "replace",
Sha256 = expectedStateSha256,
ObjectUrl = $"https://static.example/lanmountain/update/repo/sha256/{objectHash[..2]}/{objectHash}"
}
]
};
var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json");
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap));
Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig"));
var deploymentLock = new DeploymentLock(
SchemaVersion: 1,
Kind: "delta",
TargetVersion: toVersion,
PayloadPath: fileMapPath,
PayloadSha256: Sha256File(fileMapPath),
CreatedAtUtc: DateTimeOffset.UtcNow);
var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot);
Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!);
File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock));
var markerPath = UpdatePaths.GetDownloadMarkerPath(AppRoot);
File.WriteAllText(markerPath, UpdatePaths.GetDownloadMarkerContent(
manifestSha256: Sha256File(fileMapPath),
targetVersion: toVersion,
objectCount: 1));
}
public void StageLegacyUpdate(string fromVersion, string toVersion, string newState)
{
Directory.CreateDirectory(IncomingRoot);
var extractRoot = Path.Combine(IncomingRoot, "legacy-src");
Directory.CreateDirectory(extractRoot);
File.WriteAllText(Path.Combine(extractRoot, ExecutableName), $"exe-{toVersion}");
File.WriteAllText(Path.Combine(extractRoot, "state.txt"), newState);
var archivePath = Path.Combine(IncomingRoot, "update.zip");
if (File.Exists(archivePath))
{
File.Delete(archivePath);
}
System.IO.Compression.ZipFile.CreateFromDirectory(extractRoot, archivePath);
var fileMap = new SignedFileMap
{
FromVersion = fromVersion,
ToVersion = toVersion,
Files =
[
new LanMountainDesktop.Launcher.Models.UpdateFileEntry
{
Path = ExecutableName,
ArchivePath = ExecutableName,
Action = "replace",
Sha256 = Sha256File(Path.Combine(extractRoot, ExecutableName))
},
new LanMountainDesktop.Launcher.Models.UpdateFileEntry
{
Path = "state.txt",
ArchivePath = "state.txt",
Action = "replace",
Sha256 = Sha256File(Path.Combine(extractRoot, "state.txt"))
}
]
};
var fileMapPath = Path.Combine(IncomingRoot, "files.json");
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.SignedFileMap));
Sign(fileMapPath, Path.Combine(IncomingRoot, "files.json.sig"));
var deploymentLock = new DeploymentLock(
SchemaVersion: 1,
Kind: "delta",
TargetVersion: toVersion,
PayloadPath: fileMapPath,
PayloadSha256: Sha256File(fileMapPath),
CreatedAtUtc: DateTimeOffset.UtcNow);
var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot);
Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!);
File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock));
Directory.Delete(extractRoot, true);
}
public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory)
{
Directory.CreateDirectory(SnapshotsRoot);
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = sourceDirectory,
TargetDirectory = targetDirectory,
Status = "applied"
};
File.WriteAllText(
Path.Combine(SnapshotsRoot, $"{snapshot.SnapshotId}.json"),
JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
}
public void WriteStaleInstallCheckpoint(string sourceVersion, string targetVersion)
{
var checkpoint = new InstallCheckpoint
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
TargetDirectory = Path.Combine(AppRoot, $"app-{targetVersion}-999"),
IsInitialDeployment = false,
AppliedCount = 1,
VerifiedCount = 1
};
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
}
public void WriteValidPlondsResumeCheckpoint(string sourceVersion, string targetVersion)
{
var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0");
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
File.WriteAllText(Path.Combine(targetDeployment, ExecutableName), $"exe-{sourceVersion}");
var checkpoint = new InstallCheckpoint
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
TargetDirectory = targetDeployment,
IsInitialDeployment = false,
AppliedCount = 1,
VerifiedCount = 0
};
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
}
public void WriteValidLegacyResumeCheckpoint(string sourceVersion, string targetVersion)
{
var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0");
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
var checkpoint = new InstallCheckpoint
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
TargetDirectory = targetDeployment,
IsInitialDeployment = false,
AppliedCount = 0,
VerifiedCount = 0
};
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
}
public void Dispose()
{
_rsa.Dispose();
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private void Sign(string payloadPath, string signaturePath)
{
var signature = _rsa.SignData(File.ReadAllBytes(payloadPath), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(signaturePath, Convert.ToBase64String(signature));
}
private static string Sha256File(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string ExecutableName => OperatingSystem.IsWindows()
? "LanMountainDesktop.exe"
: "LanMountainDesktop";
}
}
public sealed class PlondsStaticUpdateServiceTests
{
[Fact]
public async Task CheckForUpdatesAsync_ReadsStaticLatestDistributionAndBuildsPayloadUrls()
{
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
var handler = new StaticManifestHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (path.EndsWith($"/meta/channels/stable/{platform}/latest.json", StringComparison.Ordinal))
{
return Json("""{"distributionId":"dist-1","version":"1.2.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z"}"""
.Replace("PLATFORM", platform));
}
if (path.EndsWith("/meta/distributions/dist-1.json", StringComparison.Ordinal))
{
return Json("""{"distributionId":"dist-1","version":"1.2.0","sourceVersion":"1.0.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z","fileMapUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json","fileMapSignatureUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig"}"""
.Replace("PLATFORM", platform));
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
using var client = new HttpClient(handler);
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
Assert.True(result.Success, result.ErrorMessage);
Assert.True(result.IsUpdateAvailable);
Assert.Equal("1.2.0", result.LatestVersionText);
Assert.NotNull(result.PlondsPayload);
Assert.Equal("dist-1", result.PlondsPayload.DistributionId);
Assert.Equal(platform, result.PlondsPayload.SubChannel);
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json", result.PlondsPayload.FileMapJsonUrl);
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig", result.PlondsPayload.FileMapSignatureUrl);
}
[Fact]
public async Task CheckForUpdatesAsync_WhenLatestIsMissing_ReturnsFailureForFallback()
{
using var client = new HttpClient(new StaticManifestHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
Assert.False(result.Success);
Assert.False(result.IsUpdateAvailable);
Assert.Contains("latest manifest", result.ErrorMessage);
}
[Fact]
public void ResolveCurrentPlatform_UsesCanonicalNames()
{
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
Assert.DoesNotContain("win-", platform, StringComparison.OrdinalIgnoreCase);
if (OperatingSystem.IsWindows())
{
Assert.StartsWith("windows-", platform, StringComparison.Ordinal);
}
else if (OperatingSystem.IsLinux())
{
Assert.StartsWith("linux-", platform, StringComparison.Ordinal);
}
}
private static HttpResponseMessage Json(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(responder(request));
}
}
}
public sealed class UpdatePathConsistencyTests
{
[Fact]
public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing()
{
var incoming = UpdatePaths.GetIncomingDirectory("root");
var sharedIncoming = UpdatePaths.GetIncomingDirectory("root");
Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming);
Assert.Equal(
Path.Combine("root", ".Launcher", "update", "incoming"),
sharedIncoming);
}
}
public sealed class PlondsApiManifestProviderTests
{
[Fact]
public async Task GetLatestAsync_MapsCanonicalAndLegacyFileFields()
{
using var client = new HttpClient(new StaticManifestHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (path.EndsWith("/api/plonds/v1/channels/stable/windows-x64/latest", StringComparison.Ordinal))
{
return Json("""{"distributionId":"dist-2","version":"1.2.0","publishedAt":"2026-05-06T00:00:00Z"}""");
}
if (path.EndsWith("/api/plonds/v1/distributions/dist-2", StringComparison.Ordinal))
{
return Json("""
{
"distributionId": "dist-2",
"version": "1.2.0",
"sourceVersion": "1.1.0",
"publishedAt": "2026-05-06T00:00:00Z",
"fileMapUrl": "https://static.example/filemap.json",
"signatures": [{ "signature": "https://static.example/filemap.json.sig" }],
"components": [
{
"files": [
{
"path": "LanMountainDesktop.exe",
"action": "replace",
"sha256": "abc123",
"size": 42,
"objectUrl": "https://static.example/repo/sha256/ab/abc123",
"archiveSha256": "archive123"
},
{
"path": "legacy.dll",
"op": "add",
"contentHash": "def456",
"size": 7
}
]
}
]
}
""");
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
}));
var provider = new PlondsApiManifestProvider("https://static.example", client);
var manifest = await provider.GetLatestAsync("stable", "windows-x64", new Version(1, 1, 0), CancellationToken.None);
Assert.NotNull(manifest);
Assert.Equal(UpdatePayloadKind.DeltaPlonds, manifest.Kind);
Assert.Equal("https://static.example/filemap.json.sig", manifest.FileMapSignatureUrl);
Assert.Collection(
manifest.Files,
first =>
{
Assert.Equal("replace", first.Action);
Assert.Equal("abc123", first.Sha256);
Assert.Equal("https://static.example/repo/sha256/ab/abc123", first.ObjectUrl);
Assert.Equal("archive123", first.ArchiveSha256);
},
second =>
{
Assert.Equal("add", second.Action);
Assert.Equal("def456", second.Sha256);
});
}
private static HttpResponseMessage Json(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(responder(request));
}
}
}

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

View File

@@ -0,0 +1,160 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class AppDeploymentLocator(string launcherRoot)
{
public string LauncherRoot { get; } = launcherRoot;
public string? FindCurrentDeploymentDirectory()
{
if (!Directory.Exists(LauncherRoot))
{
return null;
}
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var candidates = Directory.GetDirectories(LauncherRoot, "app-*", SearchOption.TopDirectoryOnly);
return candidates
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Where(path => File.Exists(Path.Combine(path, executable)))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
HasCurrent = File.Exists(Path.Combine(path, ".current"))
})
.OrderBy(x => x.HasCurrent ? 0 : 1)
.ThenByDescending(x => x.Version)
.Select(x => x.Path)
.FirstOrDefault();
}
public string GetCurrentVersion()
{
var deployment = FindCurrentDeploymentDirectory();
return string.IsNullOrWhiteSpace(deployment) ? "0.0.0" : ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
}
public string BuildNextDeploymentDirectory(string targetVersion)
{
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(LauncherRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
{
if (!Directory.Exists(LauncherRoot))
{
return;
}
var candidates = Directory.GetDirectories(LauncherRoot, "app-*", SearchOption.TopDirectoryOnly);
var validDeployments = candidates
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
IsCurrent = File.Exists(Path.Combine(path, ".current"))
})
.OrderByDescending(item => item.Version)
.ToList();
var versionsToKeep = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion is not null)
{
versionsToKeep.Add(currentVersion.Path);
}
foreach (var ver in validDeployments.Where(d => !d.IsDestroyed).Take(minVersionsToKeep))
{
versionsToKeep.Add(ver.Path);
}
var snapshotsDir = UpdatePaths.GetSnapshotsDirectory(LauncherRoot);
if (Directory.Exists(snapshotsDir))
{
var snapshotFiles = Directory
.GetFiles(snapshotsDir, "*.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(File.GetCreationTimeUtc)
.Take(Math.Max(1, minVersionsToKeep));
foreach (var snapshotFile in snapshotFiles)
{
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = JsonSerializer.Deserialize(json, UpdateApplyJsonContext.Default.ApplySnapshotMetadata);
if (snapshot is not null && !string.IsNullOrWhiteSpace(snapshot.SourceDirectory) && Directory.Exists(snapshot.SourceDirectory))
{
versionsToKeep.Add(snapshot.SourceDirectory);
}
}
catch
{
}
}
}
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
{
if (deployment.IsDestroyed)
{
try { File.Delete(Path.Combine(deployment.Path, ".destroy")); } catch { }
}
continue;
}
if (!deployment.IsDestroyed)
{
try { File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty); } catch { }
}
try { Directory.Delete(deployment.Path, true); } catch { }
}
}
catch
{
}
}
public static Version ParseVersionFromDirectory(string path)
{
var text = ParseVersionTextFromDirectory(path);
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
}
private static string? ParseVersionTextFromDirectory(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var segments = fileName.Split('-');
return segments.Length < 2 ? null : segments[1];
}
}

View File

@@ -1,8 +1,6 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Services.Update;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
internal sealed class DeploymentActivator(AppDeploymentLocator deploymentLocator)
{
public void Activate(string fromDeployment, string toDeployment)
{
@@ -13,24 +11,14 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
var toPartial = Path.Combine(toDeployment, ".partial");
File.WriteAllText(toCurrent, string.Empty);
if (File.Exists(toDestroy))
{
File.Delete(toDestroy);
}
if (File.Exists(fromCurrent))
{
File.Delete(fromCurrent);
}
if (File.Exists(toDestroy)) File.Delete(toDestroy);
if (File.Exists(fromCurrent)) File.Delete(fromCurrent);
File.WriteAllText(fromDestroy, string.Empty);
if (File.Exists(toPartial))
{
File.Delete(toPartial);
}
if (File.Exists(toPartial)) File.Delete(toPartial);
}
public RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot)
public RollbackAttemptResult TryRollbackOnFailure(ApplySnapshotMetadata snapshot)
{
try
{
@@ -45,16 +33,10 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
}
var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy");
if (File.Exists(destroyMarker))
{
File.Delete(destroyMarker);
}
if (File.Exists(destroyMarker)) File.Delete(destroyMarker);
var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current");
if (!File.Exists(currentMarker))
{
File.WriteAllText(currentMarker, string.Empty);
}
if (!File.Exists(currentMarker)) File.WriteAllText(currentMarker, string.Empty);
return new RollbackAttemptResult(true, null);
}
@@ -64,10 +46,7 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
}
}
public void RetainDeploymentsForRollback()
{
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
public void RetainDeploymentsForRollback() => deploymentLocator.CleanupOldDeployments(3);
}
internal sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);

View File

@@ -1,6 +1,6 @@
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths)
internal sealed class IncomingArtifactsCleaner(PlondsApplyPaths paths)
{
public void Cleanup()
{
@@ -12,7 +12,8 @@ internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths)
paths.PlondsFileMapPath,
paths.PlondsSignaturePath,
paths.PlondsUpdateMetadataPath,
paths.InstallCheckpointPath
paths.InstallCheckpointPath,
paths.DownloadMarkerPath
})
{
TryDeleteFile(path);

View File

@@ -1,11 +1,10 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class InstallCheckpointStore(UpdateEnginePaths paths)
internal sealed class ApplyInstallCheckpointStore(PlondsApplyPaths paths)
{
public InstallCheckpoint? Load()
public ApplyInstallCheckpoint? Load()
{
if (!File.Exists(paths.InstallCheckpointPath))
{
@@ -20,7 +19,7 @@ internal sealed class InstallCheckpointStore(UpdateEnginePaths paths)
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint);
return JsonSerializer.Deserialize(text, UpdateApplyJsonContext.Default.ApplyInstallCheckpoint);
}
catch
{
@@ -28,9 +27,9 @@ internal sealed class InstallCheckpointStore(UpdateEnginePaths paths)
}
}
public void Save(InstallCheckpoint checkpoint)
public void Save(ApplyInstallCheckpoint checkpoint)
{
File.WriteAllText(paths.InstallCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
File.WriteAllText(paths.InstallCheckpointPath, JsonSerializer.Serialize(checkpoint, UpdateApplyJsonContext.Default.ApplyInstallCheckpoint));
}
public void Delete()

View File

@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.Services.Update;
internal sealed class ApplyUpdateResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("stage")]
public string Stage { get; init; } = string.Empty;
[JsonPropertyName("code")]
public string Code { get; init; } = "ok";
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("currentVersion")]
public string? CurrentVersion { get; init; }
[JsonPropertyName("targetVersion")]
public string? TargetVersion { get; init; }
[JsonPropertyName("rolledBackTo")]
public string? RolledBackTo { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}
internal sealed class ApplySnapshotMetadata
{
public string SnapshotId { get; set; } = string.Empty;
public string SourceVersion { get; set; } = string.Empty;
public string? TargetVersion { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string SourceDirectory { get; set; } = string.Empty;
public string? TargetDirectory { get; set; }
public string Status { get; set; } = "pending";
}
internal sealed class ApplyInstallCheckpoint
{
public string SnapshotId { get; set; } = string.Empty;
public string SourceVersion { get; set; } = string.Empty;
public string? TargetVersion { get; set; }
public string? SourceDirectory { get; set; }
public string TargetDirectory { get; set; } = string.Empty;
public bool IsInitialDeployment { get; set; }
public int AppliedCount { get; set; }
public int VerifiedCount { get; set; }
}
internal sealed class ApplyPlondsUpdateMetadata
{
public string? DistributionId { get; set; }
public string? Channel { get; set; }
public string? SubChannel { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? FileMapPath { get; set; }
public string? FileMapSignaturePath { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class ApplyPlondsFileMap
{
public string? DistributionId { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Version { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<ApplyPlondsComponentEntry> Components { get; set; } = [];
public List<ApplyPlondsFileEntry> Files { get; set; } = [];
}
internal sealed class ApplyPlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
public string? Version { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<ApplyPlondsFileEntry> Files { get; set; } = [];
}
internal sealed class ApplyPlondsFileEntry
{
public string Path { get; set; } = string.Empty;
public string? Action { get; set; } = "replace";
public string? Url { get; set; }
public string? ObjectUrl { get; set; }
public string? ObjectPath { get; set; }
public string? ObjectKey { get; set; }
public string? ArchivePath { get; set; }
public string? Sha256 { get; set; }
public string? Sha512 { get; set; }
public string? Sha512Base64 { get; set; }
public byte[]? Sha512Bytes { get; set; }
public ApplyPlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class ApplyPlondsHashDescriptor
{
public string? Algorithm { get; set; }
public string? Value { get; set; }
public byte[]? Bytes { get; set; }
}

View File

@@ -1,8 +1,8 @@
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class UpdateEnginePaths
internal sealed class PlondsApplyPaths
{
public const string UpdateDirectoryName = "update";
public const string IncomingDirectoryName = "incoming";
@@ -16,53 +16,34 @@ internal sealed class UpdateEnginePaths
public const string PlondsObjectsDirectoryName = "objects";
public const string PublicKeyFileName = "public-key.pem";
public UpdateEnginePaths(string appRoot)
public PlondsApplyPaths(string launcherRoot)
{
AppRoot = appRoot;
var resolver = new DataLocationResolver(appRoot);
LauncherRoot = resolver.ResolveLauncherDataPath();
IncomingRoot = Path.Combine(LauncherRoot, UpdateDirectoryName, IncomingDirectoryName);
SnapshotsRoot = Path.Combine(LauncherRoot, SnapshotsDirectoryName);
InstallCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(appRoot);
LauncherRoot = launcherRoot;
IncomingRoot = UpdatePaths.GetIncomingDirectory(launcherRoot);
SnapshotsRoot = UpdatePaths.GetSnapshotsDirectory(launcherRoot);
}
public string AppRoot { get; }
public string LauncherRoot { get; }
public string IncomingRoot { get; }
public string SnapshotsRoot { get; }
public string InstallCheckpointPath => UpdatePaths.GetInstallCheckpointPath(LauncherRoot);
public string InstallCheckpointPath { get; }
public string ApplyLockPath => ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(AppRoot);
public string DeploymentLockPath => ContractsUpdate.UpdatePaths.GetDeploymentLockPath(AppRoot);
public string DownloadMarkerPath => ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(AppRoot);
public string ApplyLockPath => UpdatePaths.GetApplyInProgressLockPath(LauncherRoot);
public string DeploymentLockPath => UpdatePaths.GetDeploymentLockPath(LauncherRoot);
public string DownloadMarkerPath => UpdatePaths.GetDownloadMarkerPath(LauncherRoot);
public string FileMapPath => Path.Combine(IncomingRoot, SignedFileMapName);
public string SignaturePath => Path.Combine(IncomingRoot, SignatureFileName);
public string ArchivePath => Path.Combine(IncomingRoot, ArchiveFileName);
public string PlondsFileMapPath => Path.Combine(IncomingRoot, PlondsFileMapName);
public string PlondsSignaturePath => Path.Combine(IncomingRoot, PlondsSignatureFileName);
public string PlondsUpdateMetadataPath => Path.Combine(IncomingRoot, PlondsUpdateMetadataName);
public string PlondsObjectsRoot => Path.Combine(IncomingRoot, PlondsObjectsDirectoryName);
public string PublicKeyPath => Path.Combine(LauncherRoot, UpdateDirectoryName, PublicKeyFileName);
public string ExtractRoot => Path.Combine(IncomingRoot, "extracted");
public string PublicKeyPath => Path.Combine(LauncherRoot, ".Launcher", UpdateDirectoryName, PublicKeyFileName);
public bool HasPlondsPayload => File.Exists(PlondsFileMapPath) && File.Exists(PlondsSignaturePath);
public bool HasLegacyPayload => File.Exists(FileMapPath) && File.Exists(ArchivePath);
public string GetSnapshotPath(string snapshotId) => Path.Combine(SnapshotsRoot, $"{snapshotId}.json");
}

View File

@@ -1,13 +1,12 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal static class PlondsManifestParser
{
public static List<PlondsFileEntry> CollectFileEntries(PlondsFileMap fileMap)
public static List<ApplyPlondsFileEntry> CollectFileEntries(ApplyPlondsFileMap fileMap)
{
var files = new List<PlondsFileEntry>();
var files = new List<ApplyPlondsFileEntry>();
if (fileMap.Files is { Count: > 0 })
{
files.AddRange(fileMap.Files);
@@ -29,7 +28,7 @@ internal static class PlondsManifestParser
return files;
}
public static void PopulateFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection<PlondsFileEntry> files)
public static void PopulateFromRawJson(string fileMapJson, ApplyPlondsFileMap fileMap, ICollection<ApplyPlondsFileEntry> files)
{
if (string.IsNullOrWhiteSpace(fileMapJson))
{
@@ -62,7 +61,7 @@ internal static class PlondsManifestParser
}
}
public static PlondsUpdateMetadata? LoadMetadata(string path)
public static ApplyPlondsUpdateMetadata? LoadMetadata(string path)
{
if (!File.Exists(path))
{
@@ -74,7 +73,7 @@ internal static class PlondsManifestParser
var text = File.ReadAllText(path);
return string.IsNullOrWhiteSpace(text)
? null
: JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata);
: JsonSerializer.Deserialize(text, UpdateApplyJsonContext.Default.ApplyPlondsUpdateMetadata);
}
catch
{
@@ -82,7 +81,7 @@ internal static class PlondsManifestParser
}
}
public static string? ResolveSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
public static string? ResolveSourceVersion(ApplyPlondsFileMap fileMap, ApplyPlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.FromVersion,
@@ -91,7 +90,7 @@ internal static class PlondsManifestParser
TryGetMetadataValue(fileMap.Metadata, "sourceVersion"));
}
public static string? ResolveTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
public static string? ResolveTargetVersion(ApplyPlondsFileMap fileMap, ApplyPlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.ToVersion,
@@ -101,7 +100,7 @@ internal static class PlondsManifestParser
TryGetMetadataValue(fileMap.Metadata, "targetVersion"));
}
public static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected)
public static bool TryGetExpectedSha512(ApplyPlondsFileEntry file, out byte[] expected)
{
expected = [];
if (file.Sha512Bytes is { Length: > 0 })
@@ -134,7 +133,7 @@ internal static class PlondsManifestParser
return UpdateHash.TryParseHashBytes(file.Sha512Base64, out expected);
}
public static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected)
public static bool TryGetExpectedObjectSha512(ApplyPlondsFileEntry file, out byte[] expected)
{
expected = [];
if (file.Hash is null)
@@ -157,7 +156,7 @@ internal static class PlondsManifestParser
return UpdateHash.TryParseHashBytes(file.Hash.Value, out expected);
}
private static void ParseComponentsNode(JsonElement componentsNode, ICollection<PlondsFileEntry> files)
private static void ParseComponentsNode(JsonElement componentsNode, ICollection<ApplyPlondsFileEntry> files)
{
if (componentsNode.ValueKind == JsonValueKind.Object)
{
@@ -193,7 +192,7 @@ internal static class PlondsManifestParser
}
}
private static void ParseFilesNode(JsonElement filesNode, string? componentName, ICollection<PlondsFileEntry> files)
private static void ParseFilesNode(JsonElement filesNode, string? componentName, ICollection<ApplyPlondsFileEntry> files)
{
if (filesNode.ValueKind == JsonValueKind.Object)
{
@@ -224,9 +223,9 @@ internal static class PlondsManifestParser
}
}
private static bool TryCreateFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry)
private static bool TryCreateFileEntry(string? fallbackPath, string? componentName, JsonElement node, out ApplyPlondsFileEntry entry)
{
entry = new PlondsFileEntry();
entry = new ApplyPlondsFileEntry();
var path = ReadStringIgnoreCase(node, "path");
if (string.IsNullOrWhiteSpace(path))
{
@@ -240,7 +239,7 @@ internal static class PlondsManifestParser
var archiveSha512 = ReadByteArrayIgnoreCase(node, "archivesha512");
var archiveSha512Text = ReadStringIgnoreCase(node, "archivesha512");
entry = new PlondsFileEntry
entry = new ApplyPlondsFileEntry
{
Path = path,
Action = FirstNonEmpty(ReadStringIgnoreCase(node, "action"), "replace"),
@@ -257,7 +256,7 @@ internal static class PlondsManifestParser
if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text))
{
entry.Hash = new PlondsHashDescriptor
entry.Hash = new ApplyPlondsHashDescriptor
{
Algorithm = "sha512",
Bytes = archiveSha512,
@@ -268,7 +267,7 @@ internal static class PlondsManifestParser
}
else if (TryGetPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object)
{
entry.Hash = new PlondsHashDescriptor
entry.Hash = new ApplyPlondsHashDescriptor
{
Algorithm = ReadStringIgnoreCase(hashNode, "algorithm"),
Value = ReadStringIgnoreCase(hashNode, "value"),

View File

@@ -1,11 +1,10 @@
using System.IO.Compression;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths)
internal sealed class PlondsPayloadResolver(PlondsApplyPaths paths)
{
public string ResolveObjectPath(PlondsFileEntry file)
public string ResolveObjectPath(ApplyPlondsFileEntry file)
{
var candidates = new List<string>();
AddPathCandidates(candidates, file.ObjectPath);
@@ -18,14 +17,14 @@ internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths)
PlondsManifestParser.TryGetExpectedSha512(file, out expectedSha512))
{
var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant();
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex));
AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex));
if (hashHex.Length > 2)
{
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex));
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..]));
AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex));
AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..]));
}
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, $"{hashHex}.gz"));
AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, $"{hashHex}.gz"));
}
foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
@@ -83,15 +82,15 @@ internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths)
normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
candidates.Add(normalized);
if (!normalized.StartsWith($"{UpdateEnginePaths.PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
if (!normalized.StartsWith($"{PlondsApplyPaths.PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, normalized));
candidates.Add(Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, normalized));
}
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrWhiteSpace(fileName))
{
candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, fileName));
candidates.Add(Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, fileName));
}
}
}

View File

@@ -1,32 +1,54 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}
internal sealed class InstallProgressBridge(IProgress<InstallProgressReport>? progress) : IUpdateProgressReporter
{
private InstallCompleteReport? _complete;
public InstallCompleteReport? CompleteReport => _complete;
public void ReportProgress(InstallProgressReport report)
{
progress?.Report(report);
}
public void ReportComplete(InstallCompleteReport report)
{
_complete = report;
}
}
internal sealed class PlondsUpdateApplier(
DeploymentLocator deploymentLocator,
UpdateEnginePaths paths,
AppDeploymentLocator deploymentLocator,
PlondsApplyPaths paths,
UpdateSignatureVerifier signatureVerifier,
IUpdateProgressReporter progressReporter,
UpdateSnapshotStore snapshotStore,
InstallCheckpointStore checkpointStore,
ApplyInstallCheckpointStore checkpointStore,
DeploymentActivator deploymentActivator,
IncomingArtifactsCleaner incomingCleaner,
PlondsPayloadResolver payloadResolver)
{
public async Task<LauncherResult> ApplyAsync()
public async Task<ApplyUpdateResult> ApplyAsync()
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0));
var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, UpdateEnginePaths.PlondsSignatureFileName);
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0));
var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, PlondsApplyPaths.PlondsSignatureFileName);
if (!verifyResult.Success)
{
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message);
progressReporter.ReportComplete(new InstallCompleteReport(false, null, null, verifyResult.Message, false));
return ApplyUpdateResults.Failed("update.apply", "signature_failed", verifyResult.Message);
}
var fileMapText = await File.ReadAllTextAsync(paths.PlondsFileMapPath).ConfigureAwait(false);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap();
var fileMap = JsonSerializer.Deserialize(fileMapText, UpdateApplyJsonContext.Default.ApplyPlondsFileMap) ?? new ApplyPlondsFileMap();
var fileEntries = PlondsManifestParser.CollectFileEntries(fileMap);
if (fileEntries.Count == 0)
{
@@ -35,29 +57,23 @@ internal sealed class PlondsUpdateApplier(
if (fileEntries.Count == 0)
{
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
progressReporter.ReportComplete(new InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
return ApplyUpdateResults.Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
}
var pdcMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath);
var plondsMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath);
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
var currentVersion = deploymentLocator.GetCurrentVersion();
var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion;
var expectedSourceVersion = PlondsManifestParser.ResolveSourceVersion(fileMap, pdcMetadata);
var expectedSourceVersion = PlondsManifestParser.ResolveSourceVersion(fileMap, plondsMetadata);
if (!string.IsNullOrWhiteSpace(expectedSourceVersion) &&
!string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase))
{
return UpdateEngineResults.Failed(
"update.apply",
"version_mismatch",
$"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
return ApplyUpdateResults.Failed("update.apply", "version_mismatch", $"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
}
var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, pdcMetadata);
if (string.IsNullOrWhiteSpace(targetVersion))
{
targetVersion = sourceVersion;
}
var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, plondsMetadata);
if (string.IsNullOrWhiteSpace(targetVersion)) targetVersion = sourceVersion;
var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment);
var existingCheckpoint = checkpointStore.Load();
@@ -70,29 +86,21 @@ internal sealed class PlondsUpdateApplier(
if (existingCheckpoint is not null && !canResume)
{
return UpdateEngineResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload.");
return ApplyUpdateResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload.");
}
var targetDeployment = canResume
? existingCheckpoint!.TargetDirectory
: deploymentLocator.BuildNextDeploymentDirectory(targetVersion!);
var targetDeployment = canResume ? existingCheckpoint!.TargetDirectory : deploymentLocator.BuildNextDeploymentDirectory(targetVersion!);
var snapshot = BuildSnapshot(canResume, existingCheckpoint, sourceVersion, targetVersion, currentDeployment, targetDeployment);
var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId);
var checkpoint = canResume
? existingCheckpoint!
: BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment);
var checkpoint = canResume ? existingCheckpoint! : BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment);
try
{
snapshotStore.Save(snapshotPath, snapshot);
if (!canResume)
{
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, true);
}
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
if (Directory.Exists(targetDeployment)) Directory.Delete(targetDeployment, true);
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
}
@@ -105,14 +113,11 @@ internal sealed class PlondsUpdateApplier(
{
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
var partialMarker = Path.Combine(targetDeployment, ".partial");
if (File.Exists(partialMarker))
{
File.Delete(partialMarker);
}
if (File.Exists(partialMarker)) File.Delete(partialMarker);
}
else
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count));
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count));
deploymentActivator.Activate(currentDeployment!, targetDeployment);
}
@@ -121,10 +126,10 @@ internal sealed class PlondsUpdateApplier(
incomingCleaner.Cleanup();
deploymentActivator.RetainDeploymentsForRollback();
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
progressReporter.ReportComplete(new InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
return new LauncherResult
return new ApplyUpdateResult
{
Success = true,
Stage = "update.apply",
@@ -144,48 +149,42 @@ internal sealed class PlondsUpdateApplier(
}
}
private void ApplyFiles(IReadOnlyList<PlondsFileEntry> fileEntries, string? currentDeployment, string targetDeployment, InstallCheckpoint checkpoint)
private void ApplyFiles(IReadOnlyList<ApplyPlondsFileEntry> fileEntries, string? currentDeployment, string targetDeployment, ApplyInstallCheckpoint checkpoint)
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count));
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count));
for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++)
{
var entry = fileEntries[fileIndex];
ApplyFileEntry(entry, currentDeployment, targetDeployment);
checkpoint.AppliedCount = fileIndex + 1;
checkpointStore.Save(checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count));
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count));
}
}
private void VerifyFiles(IReadOnlyList<PlondsFileEntry> fileEntries, string targetDeployment, InstallCheckpoint checkpoint)
private void VerifyFiles(IReadOnlyList<ApplyPlondsFileEntry> fileEntries, string targetDeployment, ApplyInstallCheckpoint checkpoint)
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count));
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count));
for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++)
{
var entry = fileEntries[verifyIndex];
VerifyFileEntry(entry, targetDeployment);
checkpoint.VerifiedCount = verifyIndex + 1;
checkpointStore.Save(checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count));
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count));
}
}
private void ApplyFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment)
private void ApplyFileEntry(ApplyPlondsFileEntry file, string? currentDeployment, string targetDeployment)
{
var normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path);
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) return;
var targetPath = Path.Combine(targetDeployment, normalizedPath);
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
var targetDir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDir))
{
Directory.CreateDirectory(targetDir);
}
if (!string.IsNullOrWhiteSpace(targetDir)) Directory.CreateDirectory(targetDir);
if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase))
{
@@ -200,47 +199,30 @@ internal sealed class PlondsUpdateApplier(
ApplyUnixFileModeIfPresent(targetPath, file);
}
private static void CopyReusedFile(PlondsFileEntry file, string? currentDeployment, string normalizedPath, string targetPath)
private static void CopyReusedFile(ApplyPlondsFileEntry file, string? currentDeployment, string normalizedPath, string targetPath)
{
if (string.IsNullOrWhiteSpace(currentDeployment))
{
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available.");
}
if (string.IsNullOrWhiteSpace(currentDeployment)) throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available.");
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
UpdatePathGuard.EnsurePathWithinRoot(sourcePath, currentDeployment);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
}
if (!File.Exists(sourcePath)) throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
File.Copy(sourcePath, targetPath, overwrite: true);
ApplyUnixFileModeIfPresent(targetPath, file);
}
private static void VerifyFileEntry(PlondsFileEntry file, string targetDeployment)
private static void VerifyFileEntry(ApplyPlondsFileEntry file, string targetDeployment)
{
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) return;
var targetPath = Path.Combine(targetDeployment, UpdatePathGuard.NormalizeRelativePath(file.Path));
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
if (!File.Exists(targetPath))
{
throw new FileNotFoundException($"Expected target file was not created: {file.Path}");
}
if (!File.Exists(targetPath)) throw new FileNotFoundException($"Expected target file was not created: {file.Path}");
if (PlondsManifestParser.TryGetExpectedSha512(file, out var expectedSha512))
{
var actualSha512 = UpdateHash.ComputeSha512(targetPath);
if (!actualSha512.AsSpan().SequenceEqual(expectedSha512))
{
throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'.");
}
if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'.");
return;
}
@@ -248,29 +230,19 @@ internal sealed class PlondsUpdateApplier(
{
var expectedSha256 = UpdateHash.NormalizeHashText(file.Sha256);
var actualSha256 = UpdateHash.ComputeSha256Hex(targetPath);
if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'.");
}
if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'.");
}
}
private LauncherResult HandleFailure(
Exception ex,
bool isInitialDeployment,
string targetDeployment,
SnapshotMetadata snapshot,
string snapshotPath,
string sourceVersion,
string targetVersion)
private ApplyUpdateResult HandleFailure(Exception ex, bool isInitialDeployment, string targetDeployment, ApplySnapshotMetadata snapshot, string snapshotPath, string sourceVersion, string targetVersion)
{
if (isInitialDeployment)
{
TryDeleteDirectory(targetDeployment);
snapshot.Status = "failed";
snapshotStore.Save(snapshotPath, snapshot);
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false));
return new LauncherResult
progressReporter.ReportComplete(new InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false));
return new ApplyUpdateResult
{
Success = false,
Stage = "update.apply",
@@ -282,35 +254,27 @@ internal sealed class PlondsUpdateApplier(
};
}
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
var rollbackResult = deploymentActivator.TryRollbackOnFailure(snapshot);
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
snapshotStore.Save(snapshotPath, snapshot);
var errorMessage = rollbackResult.Success
? ex.Message
: $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success));
return new LauncherResult
var errorMessage = rollbackResult.Success ? ex.Message : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
progressReporter.ReportComplete(new InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success));
return new ApplyUpdateResult
{
Success = false,
Stage = "update.apply",
Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
Message = rollbackResult.Success
? "Failed to apply PLONDS update. Rolled back to previous version."
: "Failed to apply PLONDS update and rollback failed.",
Message = rollbackResult.Success ? "Failed to apply PLONDS update. Rolled back to previous version." : "Failed to apply PLONDS update and rollback failed.",
ErrorMessage = errorMessage,
CurrentVersion = sourceVersion,
RolledBackTo = rollbackResult.Success ? sourceVersion : null
};
}
private static SnapshotMetadata BuildSnapshot(
bool canResume,
InstallCheckpoint? existingCheckpoint,
string sourceVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment) =>
private static ApplySnapshotMetadata BuildSnapshot(bool canResume, ApplyInstallCheckpoint? existingCheckpoint, string sourceVersion, string targetVersion, string? currentDeployment, string targetDeployment) =>
new()
{
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
@@ -322,13 +286,7 @@ internal sealed class PlondsUpdateApplier(
Status = "pending"
};
private static InstallCheckpoint BuildCheckpoint(
SnapshotMetadata snapshot,
string sourceVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment,
bool isInitialDeployment) =>
private static ApplyInstallCheckpoint BuildCheckpoint(ApplySnapshotMetadata snapshot, string sourceVersion, string targetVersion, string? currentDeployment, string targetDeployment, bool isInitialDeployment) =>
new()
{
SnapshotId = snapshot.SnapshotId,
@@ -339,15 +297,9 @@ internal sealed class PlondsUpdateApplier(
IsInitialDeployment = isInitialDeployment
};
private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file)
private static void ApplyUnixFileModeIfPresent(string targetPath, ApplyPlondsFileEntry file)
{
if (OperatingSystem.IsWindows() ||
!file.Metadata.TryGetValue("unixFileMode", out var rawMode) ||
string.IsNullOrWhiteSpace(rawMode))
{
return;
}
if (OperatingSystem.IsWindows() || !file.Metadata.TryGetValue("unixFileMode", out var rawMode) || string.IsNullOrWhiteSpace(rawMode)) return;
try
{
var modeValue = Convert.ToInt32(rawMode.Trim(), 8);
@@ -362,10 +314,7 @@ internal sealed class PlondsUpdateApplier(
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
}
if (Directory.Exists(path)) Directory.Delete(path, true);
}
catch
{

View File

@@ -1,42 +1,40 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class RollbackStrategy(
DeploymentLocator deploymentLocator,
AppDeploymentLocator deploymentLocator,
UpdateSnapshotStore snapshotStore,
DeploymentActivator deploymentActivator)
{
public LauncherResult RollbackLatest()
public ApplyUpdateResult RollbackLatest()
{
var latest = snapshotStore.LoadLatest();
if (latest is null)
{
return UpdateEngineResults.Failed("update.rollback", "no_snapshot", "No snapshot found.");
return ApplyUpdateResults.Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var (snapshotPath, snapshot) = latest.Value;
if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
{
return UpdateEngineResults.Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
return ApplyUpdateResults.Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
}
if (!Directory.Exists(snapshot.SourceDirectory))
{
return UpdateEngineResults.Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}");
return ApplyUpdateResults.Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}");
}
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
return UpdateEngineResults.Failed("update.rollback", "no_current_deployment", "Current deployment not found.");
return ApplyUpdateResults.Failed("update.rollback", "no_current_deployment", "Current deployment not found.");
}
deploymentActivator.Activate(currentDeployment, snapshot.SourceDirectory);
snapshot.Status = "manual_rollback";
snapshotStore.Save(snapshotPath, snapshot);
return new LauncherResult
return new ApplyUpdateResult
{
Success = true,
Stage = "update.rollback",

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.Services.Update;
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(ApplyPlondsFileMap))]
[JsonSerializable(typeof(ApplyPlondsUpdateMetadata))]
[JsonSerializable(typeof(ApplySnapshotMetadata))]
[JsonSerializable(typeof(ApplyInstallCheckpoint))]
internal sealed partial class UpdateApplyJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,16 @@
namespace LanMountainDesktop.Services.Update;
internal static class ApplyUpdateResults
{
public static ApplyUpdateResult Failed(string stage, string code, string message)
{
return new ApplyUpdateResult
{
Success = false,
Stage = stage,
Code = code,
Message = message,
ErrorMessage = message
};
}
}

View File

@@ -1,6 +1,6 @@
using System.Security.Cryptography;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal static class UpdateHash
{

View File

@@ -37,7 +37,7 @@ internal sealed class UpdateInstallGateway
return new InstallResult(false, lockError, false, lockErrorCode);
}
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
if (payloadKind is UpdatePayloadKind.DeltaPlonds)
{
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
if (!launched)
@@ -108,7 +108,7 @@ internal sealed class UpdateInstallGateway
return false;
}
var expectedKind = payloadKind is UpdatePayloadKind.DeltaLegacy or UpdatePayloadKind.DeltaPlonds ? "delta" : "full";
var expectedKind = payloadKind is UpdatePayloadKind.DeltaPlonds ? "delta" : "full";
if (!string.Equals(deploymentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
{
errorCode = "lock_conflict";

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal static class UpdatePathGuard
{

View File

@@ -0,0 +1,16 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class UpdateRollbackGateway
{
public ApplyUpdateResult RollbackLatest(string launcherRoot)
{
var paths = new PlondsApplyPaths(launcherRoot);
var locator = new AppDeploymentLocator(launcherRoot);
var snapshotStore = new UpdateSnapshotStore(paths);
var activator = new DeploymentActivator(locator);
var strategy = new RollbackStrategy(locator, snapshotStore, activator);
return strategy.RollbackLatest();
}
}

View File

@@ -1,8 +1,8 @@
using System.Security.Cryptography;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class UpdateSignatureVerifier(UpdateEnginePaths paths)
internal sealed class UpdateSignatureVerifier(PlondsApplyPaths paths)
{
public (bool Success, string Message) Verify(string payloadPath, string signaturePath, string signatureName)
{

View File

@@ -1,18 +1,17 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths)
internal sealed class UpdateSnapshotStore(PlondsApplyPaths paths)
{
public string CreateSnapshotPath(string snapshotId) => paths.GetSnapshotPath(snapshotId);
public void Save(string path, SnapshotMetadata snapshot)
public void Save(string path, ApplySnapshotMetadata snapshot)
{
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, UpdateApplyJsonContext.Default.ApplySnapshotMetadata));
}
public (string Path, SnapshotMetadata Snapshot)? LoadLatest()
public (string Path, ApplySnapshotMetadata Snapshot)? LoadLatest()
{
if (!Directory.Exists(paths.SnapshotsRoot))
{
@@ -28,7 +27,7 @@ internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths)
return null;
}
var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata);
var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), UpdateApplyJsonContext.Default.ApplySnapshotMetadata);
return snapshot is null ? null : (snapshotPath, snapshot);
}
}

View File

@@ -15,11 +15,7 @@ public static class UpdateSettingsValues
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePlonds = "plonds-api";
public const string DownloadSourcePdc = DownloadSourcePlonds;
public const string DownloadSourceStcn = DownloadSourcePlonds;
public const string LegacyDownloadSourcePlonds = "pdc";
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
public const string LegacyDownloadSourceStcn = "stcn";
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
public const string PlondsStaticBaseUrlEnvironmentVariable = "LANMOUNTAIN_UPDATE_BASE_URL";
@@ -62,12 +58,12 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, "pdc", StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePlonds;
}
if (string.Equals(value, LegacyDownloadSourceStcn, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, "stcn", StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePlonds;
}

View File

@@ -99,7 +99,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
[ObservableProperty] private bool _forceReinstall;
[ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePlonds;
[ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeSilentDownload;
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
@@ -220,7 +220,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
LatestVersionText = report.LatestVersion ?? string.Empty;
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds;
StatusMessage = report.LatestVersion is null
? GetUpdateAvailableStatusText(string.Empty)
: string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), report.LatestVersion);
@@ -627,7 +627,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
return payloadKind switch
{
UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy => L("settings.update.type_delta", "Incremental Update"),
UpdatePayloadKind.DeltaPlonds => L("settings.update.type_delta", "Incremental Update"),
UpdatePayloadKind.FullInstaller => L("settings.update.type_reinstall", "Reinstall"),
_ => string.Empty
};

Some files were not shown because too many files have changed in this diff Show More