Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be4537b2c | ||
|
|
c5e75244af | ||
|
|
6a650873bc | ||
|
|
d004088601 | ||
|
|
a1cc0ee2bf |
278
.github/workflows/plonds-build.yml
vendored
@@ -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
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: DDSS Rollback
|
name: PLONDS Rollback
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ddss-rollback-${{ github.event.inputs.channel }}
|
group: plonds-rollback-${{ github.event.inputs.channel }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$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
|
- name: Validate rollback target assets
|
||||||
env:
|
env:
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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}"
|
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||||
--bucket "$S3_BUCKET" \
|
--bucket "$S3_BUCKET" \
|
||||||
@@ -90,10 +90,10 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
mkdir -p rollback-output
|
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"
|
manifest_url="${S3_BASE_URL}/plonds.json"
|
||||||
sig_url="${S3_BASE_URL}/ddss.json.sig"
|
sig_url="${S3_BASE_URL}/plonds.json.sig"
|
||||||
version="${RELEASE_TAG#v}"
|
version="${RELEASE_TAG#v}"
|
||||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
@@ -125,22 +125,22 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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 \
|
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||||
--bucket "$S3_BUCKET" \
|
--bucket "$S3_BUCKET" \
|
||||||
--key "$DDSS_CHANNEL_POINTER_KEY" \
|
--key "$PLONDS_CHANNEL_POINTER_KEY" \
|
||||||
--body "$pointer_file"
|
--body "$pointer_file"
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||||
--bucket "$S3_BUCKET" \
|
--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
|
- name: Print rollback summary
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
|
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"
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
name: DDSS
|
name: PLONDS Publisher
|
||||||
|
|
||||||
concurrency:
|
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
|
cancel-in-progress: false
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows:
|
workflows:
|
||||||
- PLONDS
|
- PLONDS Comparator
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
CHANNEL="stable"
|
CHANNEL="stable"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
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 }}"
|
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||||
@@ -80,13 +80,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||||
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
||||||
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
KEY="${PLONDS_SIGNING_KEY:-}"
|
||||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
||||||
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
|
|
||||||
if [[ -z "$KEY" ]]; then
|
if [[ -z "$KEY" ]]; then
|
||||||
echo "No signing key is configured."
|
echo "No signing key is configured."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -141,7 +139,7 @@ jobs:
|
|||||||
for file in release-assets/*; do
|
for file in release-assets/*; do
|
||||||
[[ -f "$file" ]] || continue
|
[[ -f "$file" ]] || continue
|
||||||
name="$(basename "$file")"
|
name="$(basename "$file")"
|
||||||
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
|
if [[ "$name" == "plonds.json" || "$name" == "plonds.json.sig" ]]; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||||
@@ -211,21 +209,21 @@ jobs:
|
|||||||
--metadata "sha256=$sha256"
|
--metadata "sha256=$sha256"
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Build DDSS manifest
|
- name: Build PLONDS manifest
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir -p ddss-output
|
mkdir -p plonds-output
|
||||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||||
build-ddss \
|
build-plonds \
|
||||||
--release-tag "$RELEASE_TAG" \
|
--release-tag "$RELEASE_TAG" \
|
||||||
--assets-dir release-assets \
|
--assets-dir release-assets \
|
||||||
--output-dir ddss-output \
|
--output-dir plonds-output \
|
||||||
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
||||||
--repository "${{ github.repository }}" \
|
--repository "${{ github.repository }}" \
|
||||||
--s3-base-url "$S3_BASE_URL"
|
--s3-base-url "$S3_BASE_URL"
|
||||||
|
|
||||||
- name: Validate DDSS asset references in Rainyun S3
|
- name: Validate PLONDS asset references in Rainyun S3
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
@@ -236,12 +234,12 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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' \
|
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
|
||||||
| sort -u)
|
| sort -u)
|
||||||
|
|
||||||
if [[ -z "$keys" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -252,15 +250,15 @@ jobs:
|
|||||||
--key "$key" >/dev/null
|
--key "$key" >/dev/null
|
||||||
done <<< "$keys"
|
done <<< "$keys"
|
||||||
|
|
||||||
- name: Upload DDSS manifest to release
|
- name: Upload PLONDS manifest to release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release upload "$RELEASE_TAG" 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:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
@@ -271,7 +269,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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")"
|
name="$(basename "$file")"
|
||||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||||
@@ -282,11 +280,11 @@ jobs:
|
|||||||
--metadata "sha256=$sha256"
|
--metadata "sha256=$sha256"
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prepare DDSS channel pointer
|
- name: Prepare PLONDS channel pointer
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
pointer_file="ddss-output/ddss-latest.json"
|
pointer_file="plonds-output/plonds-latest.json"
|
||||||
cat > "$pointer_file" <<'JSON'
|
cat > "$pointer_file" <<'JSON'
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
@@ -301,8 +299,8 @@ jobs:
|
|||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|
||||||
manifest_url="${S3_BASE_URL}/ddss.json"
|
manifest_url="${S3_BASE_URL}/plonds.json"
|
||||||
sig_url="${S3_BASE_URL}/ddss.json.sig"
|
sig_url="${S3_BASE_URL}/plonds.json.sig"
|
||||||
version="${RELEASE_TAG#v}"
|
version="${RELEASE_TAG#v}"
|
||||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
@@ -315,7 +313,7 @@ jobs:
|
|||||||
|
|
||||||
jq -e . "$pointer_file" >/dev/null
|
jq -e . "$pointer_file" >/dev/null
|
||||||
|
|
||||||
- name: Atomically publish DDSS channel pointer
|
- name: Atomically publish PLONDS channel pointer
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
@@ -326,8 +324,8 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
pointer_file="ddss-output/ddss-latest.json"
|
pointer_file="plonds-output/plonds-latest.json"
|
||||||
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json"
|
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-latest.json"
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||||
--bucket "$S3_BUCKET" \
|
--bucket "$S3_BUCKET" \
|
||||||
@@ -336,14 +334,14 @@ jobs:
|
|||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||||
--bucket "$S3_BUCKET" \
|
--bucket "$S3_BUCKET" \
|
||||||
--key "$DDSS_CHANNEL_POINTER_KEY" \
|
--key "$PLONDS_CHANNEL_POINTER_KEY" \
|
||||||
--body "$pointer_file"
|
--body "$pointer_file"
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||||
--bucket "$S3_BUCKET" \
|
--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
|
- name: Verify Rainyun S3 PLONDS output
|
||||||
env:
|
env:
|
||||||
@@ -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`.
|
|
||||||
@@ -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`
|
|
||||||
@@ -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.
|
|
||||||
512
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
# PLONDS Comparator 改造设计
|
||||||
|
|
||||||
|
> 日期:2026-05-30
|
||||||
|
> 状态:待审批
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
PLONDS(Penguin Logistics Online Network Distribution System)是 LanMountainDesktop 的文件驱动式分布式更新系统。当前 Comparator 工作流存在以下问题:
|
||||||
|
|
||||||
|
1. **产出物过于复杂**:生成 `update-{platform}.zip`、`plonds-filemap-{platform}.json`、`plonds-filemap-{platform}.json.sig`、`platform-summary-{platform}.json`、`plonds-static.zip` 等多个文件,客户端消费困难
|
||||||
|
2. **模型定义重复**:`Plonds.Shared`、`Plonds.Core`、宿主侧、Launcher 侧各自定义独立的 DTO,字段名不一致
|
||||||
|
3. **签名机制过重**:RSA 签名增加了 CI 复杂度(需要管理密钥),且对文件驱动式更新系统而言 SHA256 哈希校验已足够
|
||||||
|
4. **平台覆盖不当**:Linux 平台不需要 PLONDS 支持,macOS 尚未接入,但代码中硬编码了三个平台
|
||||||
|
5. **工作流间 artifact 传递脆弱**:Comparator → Publisher 通过 artifact 传递 `plonds-static.zip`,容易断裂
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
- 产出物精简为两个文件:`changed.zip` + `PLONDS.json`
|
||||||
|
- 去掉 RSA 签名,只用 SHA256/MD5 校验
|
||||||
|
- 只关注 Windows 平台
|
||||||
|
- 统一模型定义,消除 DTO 重复
|
||||||
|
- 保持 Comparator 和 Publisher 两个工作流的职责分离
|
||||||
|
|
||||||
|
## 3. 新产出物定义
|
||||||
|
|
||||||
|
### 3.1 changed.zip
|
||||||
|
|
||||||
|
只包含与上一版本有差异的文件(action 为 `add` 或 `replace` 的文件),目录结构与部署目录一致。
|
||||||
|
|
||||||
|
### 3.2 PLONDS.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"formatVersion": "2.0",
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"previousVersion": "1.1.0",
|
||||||
|
"isFullUpdate": false,
|
||||||
|
"requiresCleanInstall": false,
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x64",
|
||||||
|
"updatedAt": "2026-05-30T12:00:00Z",
|
||||||
|
|
||||||
|
"filesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"action": "replace",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
},
|
||||||
|
"LanMountainDesktop.dll": {
|
||||||
|
"action": "reuse",
|
||||||
|
"sha256": "def456...",
|
||||||
|
"size": 512000
|
||||||
|
},
|
||||||
|
"OldModule.dll": {
|
||||||
|
"action": "delete",
|
||||||
|
"sha256": "",
|
||||||
|
"size": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"changedFilesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"archivePath": "LanMountainDesktop.exe",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"checksums": {
|
||||||
|
"changed.zip": "md5:9a8b7c6d..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 字段语义
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `formatVersion` | string | 协议版本,固定 `"2.0"` |
|
||||||
|
| `currentVersion` | string | 当前发布版本 |
|
||||||
|
| `previousVersion` | string | 基线版本(全量更新时为 `"0.0.0"`) |
|
||||||
|
| `isFullUpdate` | bool | 是否为全量更新(找不到基线版本时为 true) |
|
||||||
|
| `requiresCleanInstall` | bool | 启动器是否也更新了——如果是,客户端不走增量流程,让用户重新运行安装器 |
|
||||||
|
| `channel` | string | 更新通道:`stable` 或 `preview` |
|
||||||
|
| `platform` | string | 平台标识:`windows-x64` |
|
||||||
|
| `updatedAt` | string | ISO 8601 时间戳 |
|
||||||
|
| `filesMap` | object | 全量文件图:每个文件的 action + sha256 + size |
|
||||||
|
| `changedFilesMap` | object | 变更文件图:只包含需要从 changed.zip 解压的文件 |
|
||||||
|
| `checksums` | object | 产出物的 MD5 值 |
|
||||||
|
|
||||||
|
### 3.4 filesMap 中 action 的值
|
||||||
|
|
||||||
|
| Action | 含义 | changed.zip 中是否包含 |
|
||||||
|
|--------|------|----------------------|
|
||||||
|
| `add` | 新增文件 | ✅ |
|
||||||
|
| `replace` | 替换文件 | ✅ |
|
||||||
|
| `reuse` | 复用上一版本文件 | ❌ |
|
||||||
|
| `delete` | 删除文件 | ❌ |
|
||||||
|
|
||||||
|
### 3.5 requiresCleanInstall 判断逻辑
|
||||||
|
|
||||||
|
比较 `LanMountainDesktop.Launcher.exe` 在当前版本和基线版本中的 SHA256:
|
||||||
|
- 如果 SHA256 不同 → `requiresCleanInstall = true`
|
||||||
|
- 如果 SHA256 相同或没有基线版本 → `requiresCleanInstall = false`
|
||||||
|
|
||||||
|
## 4. Plonds.Tool build-delta 命令改造
|
||||||
|
|
||||||
|
### 4.1 新命令签名
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta --platform <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. 对比哈希值,生成 filesMap(add/replace/reuse/delete)
|
||||||
|
6. 从当前版本目录中提取 add/replace 的文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**PlondsDeltaBuildOptions 新增参数:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
string HashAlgorithm = "sha256" // "sha256" | "md5"
|
||||||
|
```
|
||||||
|
|
||||||
|
**哈希算法对 PLONDS.json 的影响:**
|
||||||
|
|
||||||
|
- `sha256`:`filesMap` 和 `changedFilesMap` 中使用 `sha256` 字段
|
||||||
|
- `md5`:`filesMap` 和 `changedFilesMap` 中使用 `md5` 字段
|
||||||
|
|
||||||
|
### 7.4 方法二:Commit 分析模式(commit-analyze)
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下载当前版本 files-windows-x64.zip
|
||||||
|
2. 解压到临时目录
|
||||||
|
3. git log --name-only baseline_tag..current_tag
|
||||||
|
→ 得到两个版本之间的 commit 列表和涉及的源码文件
|
||||||
|
4. 过滤:只保留源码目录下的文件
|
||||||
|
5. 用简单规则映射源码文件到产物文件
|
||||||
|
6. 从当前版本的解压目录中提取映射到的产物文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
8. 如果没有源码变更 → 自动回退到方法一
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码目录过滤规则:**
|
||||||
|
|
||||||
|
只分析以下目录下的文件变更:
|
||||||
|
|
||||||
|
| 目录 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `LanMountainDesktop/` | 主宿主应用 |
|
||||||
|
| `LanMountainDesktop.Launcher/` | 启动器 |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 |
|
||||||
|
| `LanMountainDesktop.PluginSdk/` | 插件 SDK |
|
||||||
|
| `LanMountainDesktop.Appearance/` | 外观系统 |
|
||||||
|
| `LanMountainDesktop.Settings.Core/` | 设置核心 |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/` | 组件系统 |
|
||||||
|
|
||||||
|
忽略的目录:`docs/`、`scripts/`、`.github/`、`.trae/`、`PenguinLogisticsOnlineNetworkDistributionSystem/`
|
||||||
|
|
||||||
|
**源码到产物的映射规则:**
|
||||||
|
|
||||||
|
| 源码路径模式 | 映射到产物文件 |
|
||||||
|
|-------------|--------------|
|
||||||
|
| `LanMountainDesktop/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.dll`, `LanMountainDesktop.exe` |
|
||||||
|
| `LanMountainDesktop.Launcher/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.Launcher.exe` |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/**/*.cs` | `LanMountainDesktop.Shared.Contracts.dll` |
|
||||||
|
| `LanMountainDesktop.PluginSdk/**/*.cs` | `LanMountainDesktop.PluginSdk.dll` |
|
||||||
|
| `LanMountainDesktop.Appearance/**/*.cs` | `LanMountainDesktop.Appearance.dll` |
|
||||||
|
| `LanMountainDesktop.Settings.Core/**/*.cs` | `LanMountainDesktop.Settings.Core.dll` |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/**/*.cs` | `LanMountainDesktop.ComponentSystem.dll` |
|
||||||
|
| `**/*.json`(配置文件) | 同路径的 .json |
|
||||||
|
| 其他无法映射的变更 | 保守标记 → 所有核心 .dll/.exe |
|
||||||
|
|
||||||
|
**方法二在 Plonds.Tool 中的新命令:**
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta-from-commits --platform <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 命令的清理(后续处理)
|
||||||
14
CheckIpcAot/CheckIpcAot.csproj
Normal 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
@@ -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;} }
|
||||||
@@ -67,8 +67,7 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.IsDebugMode && !context.IsPreviewCommand &&
|
if (context.IsDebugMode && !context.IsPreviewCommand)
|
||||||
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
||||||
new DevDebugWindow().Show();
|
new DevDebugWindow().Show();
|
||||||
@@ -76,18 +75,9 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
|
||||||
{
|
splashWindow.Show();
|
||||||
var updateWindow = new UpdateWindow();
|
_ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||||
updateWindow.Show();
|
|
||||||
_ = ApplyUpdateEntryHandler.RunAsync(desktop, context, updateWindow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
|
|
||||||
splashWindow.Show();
|
|
||||||
_ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,9 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
||||||
|
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||||
|
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||||
|
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ internal sealed class CommandContext
|
|||||||
[
|
[
|
||||||
"launch",
|
"launch",
|
||||||
AirAppBrokerCommand,
|
AirAppBrokerCommand,
|
||||||
"apply-update",
|
|
||||||
"preview-splash",
|
"preview-splash",
|
||||||
"preview-error",
|
"preview-error",
|
||||||
"preview-update",
|
"preview-update",
|
||||||
@@ -70,7 +69,6 @@ internal sealed class CommandContext
|
|||||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsMaintenanceCommand =>
|
public bool IsMaintenanceCommand =>
|
||||||
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -118,11 +116,6 @@ internal sealed class CommandContext
|
|||||||
return "debug-preview";
|
return "debug-preview";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "apply-update";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return "plugin-install";
|
return "plugin-install";
|
||||||
@@ -143,7 +136,6 @@ internal sealed class CommandContext
|
|||||||
"normal" => "normal",
|
"normal" => "normal",
|
||||||
"restart" => "restart",
|
"restart" => "restart",
|
||||||
"postinstall" => "postinstall",
|
"postinstall" => "postinstall",
|
||||||
"apply-update" => "apply-update",
|
|
||||||
"plugin-install" => "plugin-install",
|
"plugin-install" => "plugin-install",
|
||||||
"debug-preview" => "debug-preview",
|
"debug-preview" => "debug-preview",
|
||||||
_ => null
|
_ => null
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ global using LanMountainDesktop.Launcher.Ipc;
|
|||||||
global using LanMountainDesktop.Launcher.Oobe;
|
global using LanMountainDesktop.Launcher.Oobe;
|
||||||
global using LanMountainDesktop.Launcher.Plugins;
|
global using LanMountainDesktop.Launcher.Plugins;
|
||||||
global using LanMountainDesktop.Launcher.Startup;
|
global using LanMountainDesktop.Launcher.Startup;
|
||||||
global using LanMountainDesktop.Launcher.Update;
|
|
||||||
|
|||||||
@@ -35,15 +35,14 @@ internal static class Commands
|
|||||||
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
||||||
{
|
{
|
||||||
var appRoot = ResolveAppRoot(context);
|
var appRoot = ResolveAppRoot(context);
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
_ = new DeploymentLocator(appRoot);
|
||||||
var updateEngine = UpdateEngineFactory.Create(deploymentLocator);
|
|
||||||
var pluginInstaller = new PluginInstallerService();
|
var pluginInstaller = new PluginInstallerService();
|
||||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||||
|
|
||||||
LauncherResult result;
|
LauncherResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
|
result = ExecuteCore(context, pluginInstaller, pluginUpgrades);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -61,16 +60,13 @@ internal static class Commands
|
|||||||
return result.Success ? 0 : 1;
|
return result.Success ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<LauncherResult> ExecuteCoreAsync(
|
private static LauncherResult ExecuteCore(
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
IUpdateEngine updateEngine,
|
|
||||||
PluginInstallerService pluginInstaller,
|
PluginInstallerService pluginInstaller,
|
||||||
PluginUpgradeQueueService pluginUpgrades)
|
PluginUpgradeQueueService pluginUpgrades)
|
||||||
{
|
{
|
||||||
switch (context.Command.ToLowerInvariant())
|
switch (context.Command.ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "update":
|
|
||||||
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
|
|
||||||
case "plugin":
|
case "plugin":
|
||||||
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
||||||
default:
|
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(
|
private static LauncherResult ExecutePluginCommand(
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
PluginInstallerService pluginInstaller,
|
PluginInstallerService pluginInstaller,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ using LanMountainDesktop.Shared.Contracts.Update;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Ipc;
|
namespace LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|
||||||
|
internal interface IUpdateProgressReporter
|
||||||
|
{
|
||||||
|
void ReportProgress(InstallProgressReport report);
|
||||||
|
void ReportComplete(InstallCompleteReport report);
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
|
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
|
||||||
{
|
{
|
||||||
private const int LengthPrefixSize = 4;
|
private const int LengthPrefixSize = 4;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
<!-- 忽略某些警告 -->
|
<!-- 忽略某些警告 -->
|
||||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
@@ -60,7 +61,8 @@
|
|||||||
|
|
||||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
<!-- [Fix]: 必须设置为 true 以支持 dotnetCampus.Ipc 内部的反射序列化。相关类型的剪裁保护通过 AppJsonContext 保证 -->
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
|
||||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,15 +31,6 @@ internal static class LaunchEntryHandler
|
|||||||
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
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
|
internal static class AirAppBrokerEntryHandler
|
||||||
{
|
{
|
||||||
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
|
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ internal sealed class LauncherOrchestrator
|
|||||||
private readonly CommandContext _context;
|
private readonly CommandContext _context;
|
||||||
private readonly DeploymentLocator _deploymentLocator;
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
private readonly OobeStateService _oobeStateService;
|
private readonly OobeStateService _oobeStateService;
|
||||||
private readonly IUpdateEngine _updateEngine;
|
|
||||||
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
||||||
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
||||||
private readonly DataLocationResolver _dataLocationResolver;
|
private readonly DataLocationResolver _dataLocationResolver;
|
||||||
@@ -24,7 +23,6 @@ internal sealed class LauncherOrchestrator
|
|||||||
CommandContext context,
|
CommandContext context,
|
||||||
DeploymentLocator deploymentLocator,
|
DeploymentLocator deploymentLocator,
|
||||||
OobeStateService oobeStateService,
|
OobeStateService oobeStateService,
|
||||||
IUpdateEngine updateEngine,
|
|
||||||
StartupAttemptRegistry startupAttemptRegistry,
|
StartupAttemptRegistry startupAttemptRegistry,
|
||||||
LauncherCoordinatorIpcServer? coordinatorIpcServer = null,
|
LauncherCoordinatorIpcServer? coordinatorIpcServer = null,
|
||||||
LaunchPipeline? pipeline = null)
|
LaunchPipeline? pipeline = null)
|
||||||
@@ -32,7 +30,6 @@ internal sealed class LauncherOrchestrator
|
|||||||
_context = context;
|
_context = context;
|
||||||
_deploymentLocator = deploymentLocator;
|
_deploymentLocator = deploymentLocator;
|
||||||
_oobeStateService = oobeStateService;
|
_oobeStateService = oobeStateService;
|
||||||
_updateEngine = updateEngine;
|
|
||||||
_startupAttemptRegistry = startupAttemptRegistry;
|
_startupAttemptRegistry = startupAttemptRegistry;
|
||||||
_coordinatorIpcServer = coordinatorIpcServer;
|
_coordinatorIpcServer = coordinatorIpcServer;
|
||||||
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
|
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
|
||||||
@@ -45,7 +42,6 @@ internal sealed class LauncherOrchestrator
|
|||||||
[
|
[
|
||||||
new CleanupDeploymentsPhase(),
|
new CleanupDeploymentsPhase(),
|
||||||
new ExistingHostProbePhase(),
|
new ExistingHostProbePhase(),
|
||||||
new ApplyPendingUpdatePhase(),
|
|
||||||
new OobeGatePhase(),
|
new OobeGatePhase(),
|
||||||
new LaunchHostPhase(),
|
new LaunchHostPhase(),
|
||||||
new MonitorStartupPhase()
|
new MonitorStartupPhase()
|
||||||
@@ -217,7 +213,6 @@ internal sealed class LauncherOrchestrator
|
|||||||
CommandContext = _context,
|
CommandContext = _context,
|
||||||
DeploymentLocator = _deploymentLocator,
|
DeploymentLocator = _deploymentLocator,
|
||||||
OobeStateService = _oobeStateService,
|
OobeStateService = _oobeStateService,
|
||||||
UpdateEngine = _updateEngine,
|
|
||||||
StartupAttemptRegistry = _startupAttemptRegistry,
|
StartupAttemptRegistry = _startupAttemptRegistry,
|
||||||
CoordinatorIpcServer = _coordinatorIpcServer,
|
CoordinatorIpcServer = _coordinatorIpcServer,
|
||||||
DataLocationResolver = _dataLocationResolver,
|
DataLocationResolver = _dataLocationResolver,
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ internal static class LauncherServiceRegistration
|
|||||||
services.AddSingleton(new DeploymentLocator(appRoot));
|
services.AddSingleton(new DeploymentLocator(appRoot));
|
||||||
services.AddSingleton(sp => new OobeStateService(appRoot));
|
services.AddSingleton(sp => new OobeStateService(appRoot));
|
||||||
services.AddSingleton(sp => new DataLocationResolver(appRoot));
|
services.AddSingleton(sp => new DataLocationResolver(appRoot));
|
||||||
services.AddSingleton(sp => UpdateEngineFactory.Create(sp.GetRequiredService<DeploymentLocator>()));
|
|
||||||
services.AddSingleton<HostLaunchService>();
|
services.AddSingleton<HostLaunchService>();
|
||||||
services.AddSingleton<StartupAttemptRegistry>();
|
services.AddSingleton<StartupAttemptRegistry>();
|
||||||
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
|
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
|
||||||
services.AddSingleton<ILaunchPhase, ExistingHostProbePhase>();
|
services.AddSingleton<ILaunchPhase, ExistingHostProbePhase>();
|
||||||
services.AddSingleton<ILaunchPhase, ApplyPendingUpdatePhase>();
|
|
||||||
services.AddSingleton<ILaunchPhase, OobeGatePhase>();
|
services.AddSingleton<ILaunchPhase, OobeGatePhase>();
|
||||||
services.AddSingleton<ILaunchPhase, LaunchHostPhase>();
|
services.AddSingleton<ILaunchPhase, LaunchHostPhase>();
|
||||||
services.AddSingleton<ILaunchPhase, MonitorStartupPhase>();
|
services.AddSingleton<ILaunchPhase, MonitorStartupPhase>();
|
||||||
@@ -47,7 +45,6 @@ internal static class LauncherServiceRegistration
|
|||||||
context,
|
context,
|
||||||
services.GetRequiredService<DeploymentLocator>(),
|
services.GetRequiredService<DeploymentLocator>(),
|
||||||
services.GetRequiredService<OobeStateService>(),
|
services.GetRequiredService<OobeStateService>(),
|
||||||
services.GetRequiredService<IUpdateEngine>(),
|
|
||||||
startupAttemptRegistry,
|
startupAttemptRegistry,
|
||||||
coordinatorServer,
|
coordinatorServer,
|
||||||
services.GetRequiredService<LaunchPipeline>());
|
services.GetRequiredService<LaunchPipeline>());
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ internal sealed class LaunchContext
|
|||||||
public required CommandContext CommandContext { get; init; }
|
public required CommandContext CommandContext { get; init; }
|
||||||
public required DeploymentLocator DeploymentLocator { get; init; }
|
public required DeploymentLocator DeploymentLocator { get; init; }
|
||||||
public required OobeStateService OobeStateService { get; init; }
|
public required OobeStateService OobeStateService { get; init; }
|
||||||
public required IUpdateEngine UpdateEngine { get; init; }
|
|
||||||
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
|
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
|
||||||
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
|
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
|
||||||
public required DataLocationResolver DataLocationResolver { get; init; }
|
public required DataLocationResolver DataLocationResolver { get; init; }
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Update;
|
|
||||||
|
|
||||||
public interface IUpdateProgressReporter
|
|
||||||
{
|
|
||||||
void ReportProgress(InstallProgressReport report);
|
|
||||||
void ReportComplete(InstallCompleteReport report);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) { }
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ using LanMountainDesktop.Launcher.Resources;
|
|||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度
|
/// 更新进度窗口 - 用于预览模式显示更新/插件升级进度
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class UpdateWindow : Window
|
public partial class UpdateWindow : Window
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed record UpdateManifest(
|
|||||||
IReadOnlyList<UpdateMirrorAsset>? InstallerMirrors,
|
IReadOnlyList<UpdateMirrorAsset>? InstallerMirrors,
|
||||||
IReadOnlyDictionary<string, string> Metadata)
|
IReadOnlyDictionary<string, string> Metadata)
|
||||||
{
|
{
|
||||||
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
|
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds;
|
||||||
|
|
||||||
public long EstimatedDeltaBytes
|
public long EstimatedDeltaBytes
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ public enum UpdatePhase
|
|||||||
public enum UpdatePayloadKind
|
public enum UpdatePayloadKind
|
||||||
{
|
{
|
||||||
DeltaPlonds,
|
DeltaPlonds,
|
||||||
DeltaLegacy,
|
|
||||||
FullInstaller
|
FullInstaller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public sealed class CommandContextTests
|
|||||||
{
|
{
|
||||||
{ [], "normal" },
|
{ [], "normal" },
|
||||||
{ ["preview-oobe"], "debug-preview" },
|
{ ["preview-oobe"], "debug-preview" },
|
||||||
{ ["apply-update"], "apply-update" },
|
{ ["apply-update"], "normal" },
|
||||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||||
};
|
};
|
||||||
|
|||||||
7
LanMountainDesktop.Tests/GlobalUsings.cs
Normal 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;
|
||||||
69
LanMountainDesktop.Tests/HostStartupMonitorTests.cs
Normal 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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,7 @@ public sealed class LauncherArchitectureTests
|
|||||||
{
|
{
|
||||||
var guardedFiles = new[]
|
var guardedFiles = new[]
|
||||||
{
|
{
|
||||||
Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs"),
|
Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs")
|
||||||
Path.Combine(LauncherProjectRoot, "Shell", "ApplyUpdateGuiFlow.cs")
|
|
||||||
}.Concat(Directory.EnumerateFiles(
|
}.Concat(Directory.EnumerateFiles(
|
||||||
Path.Combine(LauncherProjectRoot, "Shell", "EntryHandlers"),
|
Path.Combine(LauncherProjectRoot, "Shell", "EntryHandlers"),
|
||||||
"*.cs",
|
"*.cs",
|
||||||
@@ -49,9 +48,8 @@ public sealed class LauncherArchitectureTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LauncherFacadeAndCompositionRootStayThin()
|
public void LauncherCompositionRootStaysThin()
|
||||||
{
|
{
|
||||||
AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Update", "UpdateEngineFacade.cs"), 140);
|
|
||||||
AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Shell", "LauncherCompositionRoot.cs"), 80);
|
AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Shell", "LauncherCompositionRoot.cs"), 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ global using LanMountainDesktop.Launcher.Infrastructure;
|
|||||||
global using LanMountainDesktop.Launcher.Ipc;
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
global using LanMountainDesktop.Launcher.Oobe;
|
global using LanMountainDesktop.Launcher.Oobe;
|
||||||
global using LanMountainDesktop.Launcher.Startup;
|
global using LanMountainDesktop.Launcher.Startup;
|
||||||
global using LanMountainDesktop.Launcher.Update;
|
|
||||||
@@ -134,13 +134,13 @@ public sealed class PendingPluginUpgradeServiceTests : IDisposable
|
|||||||
return packagePath;
|
return packagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
private static LanMountainDesktop.PluginSdk.PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(packagePath);
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
var entry = archive.GetEntry(PluginSdkInfo.ManifestFileName)
|
var entry = archive.GetEntry(PluginSdkInfo.ManifestFileName)
|
||||||
?? throw new InvalidOperationException("Missing plugin manifest.");
|
?? throw new InvalidOperationException("Missing plugin manifest.");
|
||||||
using var stream = entry.Open();
|
using var stream = entry.Open();
|
||||||
return PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}");
|
return LanMountainDesktop.PluginSdk.PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -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
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -146,15 +146,10 @@ public sealed class WindowLayerIsolationTests
|
|||||||
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
|
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
|
||||||
{
|
{
|
||||||
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
|
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
|
||||||
var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs");
|
|
||||||
|
|
||||||
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
|
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
|
||||||
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
|
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
|
||||||
Assert.Contains("SendToBottom", desktopWidgetWindow);
|
Assert.Contains("SendToBottom", desktopWidgetWindow);
|
||||||
|
|
||||||
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow);
|
|
||||||
Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow);
|
|
||||||
Assert.Contains("SendToBottom", transparentOverlayWindow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ public partial class App : Application
|
|||||||
private DispatcherTimer? _shellRecoveryTimer;
|
private DispatcherTimer? _shellRecoveryTimer;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
|
||||||
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
||||||
private bool _isExitingFusedDesktopEditMode;
|
|
||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
private PublicIpcHostService? _publicIpcHostService;
|
private PublicIpcHostService? _publicIpcHostService;
|
||||||
@@ -454,22 +452,10 @@ public partial class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate();
|
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||||
fusedDesktopManager.EnterEditMode();
|
|
||||||
|
|
||||||
EnsureTransparentOverlayWindow();
|
|
||||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow.Show();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_fusedComponentLibraryWindow is { } existingWindow)
|
if (_fusedComponentLibraryWindow is { } existingWindow)
|
||||||
{
|
{
|
||||||
if (_transparentOverlayWindow is not null)
|
|
||||||
{
|
|
||||||
existingWindow.SetOverlayWindow(_transparentOverlayWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingWindow.IsVisible)
|
if (!existingWindow.IsVisible)
|
||||||
{
|
{
|
||||||
existingWindow.Show();
|
existingWindow.Show();
|
||||||
@@ -477,7 +463,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (centerInWorkArea)
|
if (centerInWorkArea)
|
||||||
{
|
{
|
||||||
existingWindow.CenterInWorkArea(_transparentOverlayWindow);
|
existingWindow.CenterInWorkArea();
|
||||||
}
|
}
|
||||||
|
|
||||||
existingWindow.Activate();
|
existingWindow.Activate();
|
||||||
@@ -486,16 +472,12 @@ public partial class App : Application
|
|||||||
|
|
||||||
var window = new FusedDesktopComponentLibraryWindow();
|
var window = new FusedDesktopComponentLibraryWindow();
|
||||||
_fusedComponentLibraryWindow = window;
|
_fusedComponentLibraryWindow = window;
|
||||||
if (_transparentOverlayWindow is not null)
|
|
||||||
{
|
|
||||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Closed += OnFusedComponentLibraryWindowClosed;
|
window.Closed += OnFusedComponentLibraryWindowClosed;
|
||||||
window.Show();
|
window.Show();
|
||||||
if (centerInWorkArea)
|
if (centerInWorkArea)
|
||||||
{
|
{
|
||||||
window.CenterInWorkArea(_transparentOverlayWindow);
|
window.CenterInWorkArea();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Activate();
|
window.Activate();
|
||||||
@@ -503,7 +485,13 @@ public partial class App : Application
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
|
if (_fusedComponentLibraryWindow is { } libWindow)
|
||||||
|
{
|
||||||
|
_fusedComponentLibraryWindow = null;
|
||||||
|
libWindow.Closed -= OnFusedComponentLibraryWindowClosed;
|
||||||
|
libWindow.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,50 +508,13 @@ public partial class App : Application
|
|||||||
_fusedComponentLibraryWindow = null;
|
_fusedComponentLibraryWindow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.PreserveEditModeOnClose && !_isExitingFusedDesktopEditMode)
|
|
||||||
{
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExitFusedDesktopEditModeFromUi(bool closeLibrary)
|
|
||||||
{
|
|
||||||
if (_isExitingFusedDesktopEditMode)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isExitingFusedDesktopEditMode = true;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (closeLibrary && _fusedComponentLibraryWindow is { } libraryWindow)
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
{
|
|
||||||
_fusedComponentLibraryWindow = null;
|
|
||||||
libraryWindow.Closed -= OnFusedComponentLibraryWindowClosed;
|
|
||||||
libraryWindow.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow?.SaveLayoutAndHide();
|
|
||||||
}
|
|
||||||
catch (Exception overlayEx)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
|
||||||
}
|
|
||||||
catch (Exception exitEx)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_isExitingFusedDesktopEditMode = false;
|
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode after library closed.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,11 +841,6 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow.Hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||||
mainWindow.PrepareEnterAnimation();
|
mainWindow.PrepareEnterAnimation();
|
||||||
|
|
||||||
@@ -938,26 +884,6 @@ public partial class App : Application
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureTransparentOverlayWindow()
|
|
||||||
{
|
|
||||||
if (_transparentOverlayWindow is null)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow = new TransparentOverlayWindow();
|
|
||||||
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
RestoreOrCreateMainWindow("TransparentOverlay");
|
|
||||||
};
|
|
||||||
_transparentOverlayWindow.ExitEditRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
};
|
|
||||||
_transparentOverlayWindow.RestoreComponentLibraryRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
|
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
|
||||||
{
|
{
|
||||||
@@ -1263,31 +1189,16 @@ public partial class App : Application
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_fusedComponentLibraryWindow = null;
|
_fusedComponentLibraryWindow = null;
|
||||||
try
|
|
||||||
{
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null)
|
try
|
||||||
{
|
{
|
||||||
try
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
{
|
}
|
||||||
_transparentOverlayWindow.Close();
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
AppLogger.Warn("FusedDesktop", "Failed to shut down fused desktop manager during exit cleanup.", ex);
|
||||||
{
|
|
||||||
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
@@ -1572,13 +1483,6 @@ public partial class App : Application
|
|||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"DesktopShell",
|
"DesktopShell",
|
||||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
|
||||||
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
|
|
||||||
{
|
|
||||||
EnsureTransparentOverlayWindow();
|
|
||||||
_transparentOverlayWindow?.Show();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1668,7 +1572,6 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (IsMainWindowDesktopLayerEnabled())
|
if (IsMainWindowDesktopLayerEnabled())
|
||||||
{
|
{
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
_mainWindow.ShowInTaskbar = false;
|
_mainWindow.ShowInTaskbar = false;
|
||||||
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
|
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
|
||||||
@@ -1697,7 +1600,6 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
BIN
LanMountainDesktop/Assets/endfiled/Listen.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
LanMountainDesktop/Assets/endfiled/admire_happy.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
LanMountainDesktop/Assets/endfiled/believe_power.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
LanMountainDesktop/Assets/endfiled/error.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
LanMountainDesktop/Assets/endfiled/find.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
LanMountainDesktop/Assets/endfiled/good.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
LanMountainDesktop/Assets/endfiled/haha.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
LanMountainDesktop/Assets/endfiled/happy_paopao.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
LanMountainDesktop/Assets/endfiled/huh.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
LanMountainDesktop/Assets/endfiled/idea.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
LanMountainDesktop/Assets/endfiled/idontcare.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
LanMountainDesktop/Assets/endfiled/listen_write.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
LanMountainDesktop/Assets/endfiled/love.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
LanMountainDesktop/Assets/endfiled/newest_yes.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
LanMountainDesktop/Assets/endfiled/no_good.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
LanMountainDesktop/Assets/endfiled/nonononono.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
LanMountainDesktop/Assets/endfiled/oh.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
LanMountainDesktop/Assets/endfiled/oobe_hello.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
LanMountainDesktop/Assets/endfiled/play.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
LanMountainDesktop/Assets/endfiled/thinking.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
LanMountainDesktop/Assets/endfiled/wait_something.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
LanMountainDesktop/Assets/endfiled/what.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
LanMountainDesktop/Assets/endfiled/what_happen.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
LanMountainDesktop/Assets/endfiled/write.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
@@ -11,46 +12,46 @@ using LanMountainDesktop.Views.Components;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 融合桌面中央管理器服务接口
|
|
||||||
/// </summary>
|
|
||||||
public interface IFusedDesktopManagerService
|
public interface IFusedDesktopManagerService
|
||||||
{
|
{
|
||||||
void Initialize();
|
void Initialize();
|
||||||
void EnterEditMode();
|
|
||||||
void ExitEditMode();
|
|
||||||
void ReloadWidgets();
|
void ReloadWidgets();
|
||||||
void Shutdown();
|
void Shutdown();
|
||||||
|
void AddComponent(string componentId);
|
||||||
|
void RemoveComponent(string placementId);
|
||||||
|
void EnterEditMode();
|
||||||
|
void ExitEditMode();
|
||||||
|
bool IsEditMode { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||||
{
|
{
|
||||||
private readonly IFusedDesktopLayoutService _layoutService;
|
private readonly IFusedDesktopLayoutService _layoutService;
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
|
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
|
||||||
|
|
||||||
// 基础服务依赖
|
|
||||||
private readonly IWeatherInfoService _weatherDataService;
|
private readonly IWeatherInfoService _weatherDataService;
|
||||||
private readonly TimeZoneService _timeZoneService;
|
private readonly TimeZoneService _timeZoneService;
|
||||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||||
|
|
||||||
private ComponentRegistry? _componentRegistry;
|
private ComponentRegistry? _componentRegistry;
|
||||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||||
private bool _isEditMode;
|
private bool _isEditMode;
|
||||||
|
|
||||||
private const double DefaultCellSize = 100;
|
private const double DefaultCellSize = 100;
|
||||||
|
private const double DefaultComponentWidth = 200;
|
||||||
|
private const double DefaultComponentHeight = 200;
|
||||||
|
|
||||||
|
public bool IsEditMode => _isEditMode;
|
||||||
|
|
||||||
public FusedDesktopManagerService(
|
public FusedDesktopManagerService(
|
||||||
IFusedDesktopLayoutService layoutService,
|
IFusedDesktopLayoutService layoutService,
|
||||||
ISettingsFacadeService settingsFacade)
|
ISettingsFacadeService settingsFacade)
|
||||||
{
|
{
|
||||||
_layoutService = layoutService;
|
_layoutService = layoutService;
|
||||||
_settingsFacade = settingsFacade;
|
_settingsFacade = settingsFacade;
|
||||||
|
|
||||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||||
}
|
}
|
||||||
@@ -58,15 +59,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
public void Initialize()
|
public void Initialize()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows()) return;
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
// 检查融合桌面功能是否启用
|
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
if (!appSnapshot.EnableFusedDesktop)
|
if (!appSnapshot.EnableFusedDesktop)
|
||||||
{
|
{
|
||||||
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
|
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureRegistries();
|
EnsureRegistries();
|
||||||
ReloadWidgets();
|
ReloadWidgets();
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
private void EnsureRegistries()
|
private void EnsureRegistries()
|
||||||
{
|
{
|
||||||
if (_componentRuntimeRegistry is not null) return;
|
if (_componentRuntimeRegistry is not null) return;
|
||||||
|
|
||||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||||
@@ -88,12 +88,12 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
if (_isEditMode) return;
|
if (_isEditMode) return;
|
||||||
_isEditMode = true;
|
_isEditMode = true;
|
||||||
|
|
||||||
// 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层
|
|
||||||
// 这样可以保持组件的运行状态(动画、输入等)
|
|
||||||
foreach (var window in _widgetWindows.Values)
|
foreach (var window in _widgetWindows.Values)
|
||||||
{
|
{
|
||||||
window.Hide();
|
window.SetEditMode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("FusedDesktop", "Entered edit mode.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ExitEditMode()
|
public void ExitEditMode()
|
||||||
@@ -101,25 +101,91 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
if (!_isEditMode) return;
|
if (!_isEditMode) return;
|
||||||
_isEditMode = false;
|
_isEditMode = false;
|
||||||
|
|
||||||
// 编辑完成,重新加载布局(可能已发生更改)并显示
|
foreach (var window in _widgetWindows.Values)
|
||||||
ReloadWidgets();
|
{
|
||||||
|
window.SetEditMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("FusedDesktop", "Exited edit mode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddComponent(string componentId)
|
||||||
|
{
|
||||||
|
EnsureRegistries();
|
||||||
|
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {componentId}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var placement = new FusedDesktopComponentPlacementSnapshot
|
||||||
|
{
|
||||||
|
PlacementId = Guid.NewGuid().ToString("N"),
|
||||||
|
ComponentId = componentId,
|
||||||
|
Width = DefaultComponentWidth,
|
||||||
|
Height = DefaultComponentHeight
|
||||||
|
};
|
||||||
|
|
||||||
|
var screen = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
|
||||||
|
?.MainWindow?.Screens.Primary;
|
||||||
|
if (screen is not null)
|
||||||
|
{
|
||||||
|
var scaling = screen.Scaling;
|
||||||
|
var workArea = screen.WorkingArea;
|
||||||
|
placement.X = (workArea.Width / scaling - placement.Width) / 2;
|
||||||
|
placement.Y = (workArea.Height / scaling - placement.Height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
_layoutService.AddComponentPlacement(placement);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var window = CreateWidgetWindow(placement);
|
||||||
|
if (window != null)
|
||||||
|
{
|
||||||
|
_widgetWindows[placement.PlacementId] = window;
|
||||||
|
if (_isEditMode)
|
||||||
|
{
|
||||||
|
window.SetEditMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Show();
|
||||||
|
window.Position = new PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
|
window.RefreshDesktopLayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktopMgr", $"Failed to create widget window for {componentId}", ex);
|
||||||
|
_layoutService.RemoveComponentPlacement(placement.PlacementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("FusedDesktopMgr", $"Added component '{componentId}' with placement '{placement.PlacementId}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveComponent(string placementId)
|
||||||
|
{
|
||||||
|
if (_widgetWindows.Remove(placementId, out var windowToRemove))
|
||||||
|
{
|
||||||
|
windowToRemove.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_layoutService.RemoveComponentPlacement(placementId);
|
||||||
|
AppLogger.Info("FusedDesktopMgr", $"Removed component placement '{placementId}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ReloadWidgets()
|
public void ReloadWidgets()
|
||||||
{
|
{
|
||||||
if (_isEditMode) return; // 编辑模式下不渲染小窗口
|
|
||||||
|
|
||||||
var layout = _layoutService.Load();
|
var layout = _layoutService.Load();
|
||||||
var existingIds = new HashSet<string>(_widgetWindows.Keys);
|
var existingIds = new HashSet<string>(_widgetWindows.Keys);
|
||||||
|
|
||||||
foreach (var placement in layout.ComponentPlacements)
|
foreach (var placement in layout.ComponentPlacements)
|
||||||
{
|
{
|
||||||
existingIds.Remove(placement.PlacementId);
|
existingIds.Remove(placement.PlacementId);
|
||||||
|
|
||||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||||
{
|
{
|
||||||
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
|
existingWindow.Position = new PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
|
||||||
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
||||||
if (existingWindow.IsVisible == false)
|
if (existingWindow.IsVisible == false)
|
||||||
{
|
{
|
||||||
@@ -130,15 +196,19 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 新组件,生成窗口
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var window = CreateWidgetWindow(placement);
|
var window = CreateWidgetWindow(placement);
|
||||||
if (window != null)
|
if (window != null)
|
||||||
{
|
{
|
||||||
_widgetWindows[placement.PlacementId] = window;
|
_widgetWindows[placement.PlacementId] = window;
|
||||||
|
if (_isEditMode)
|
||||||
|
{
|
||||||
|
window.SetEditMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
window.Show();
|
window.Show();
|
||||||
window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
window.Position = new PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
window.RefreshDesktopLayer();
|
window.RefreshDesktopLayer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,8 +218,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除被删除的组件
|
|
||||||
foreach (var id in existingIds)
|
foreach (var id in existingIds)
|
||||||
{
|
{
|
||||||
if (_widgetWindows.Remove(id, out var windowToRemove))
|
if (_widgetWindows.Remove(id, out var windowToRemove))
|
||||||
@@ -179,7 +248,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
|
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var control = descriptor.CreateControl(
|
var control = descriptor.CreateControl(
|
||||||
DefaultCellSize,
|
DefaultCellSize,
|
||||||
_timeZoneService,
|
_timeZoneService,
|
||||||
@@ -188,28 +257,24 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
_calculatorDataService,
|
_calculatorDataService,
|
||||||
_settingsFacade,
|
_settingsFacade,
|
||||||
placement.PlacementId);
|
placement.PlacementId);
|
||||||
|
|
||||||
// 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度)
|
|
||||||
control.Width = placement.Width;
|
control.Width = placement.Width;
|
||||||
control.Height = placement.Height;
|
control.Height = placement.Height;
|
||||||
|
|
||||||
var window = new DesktopWidgetWindow(control);
|
var window = new DesktopWidgetWindow(control, placement.PlacementId);
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 工厂
|
|
||||||
/// </summary>
|
|
||||||
public static class FusedDesktopManagerServiceFactory
|
public static class FusedDesktopManagerServiceFactory
|
||||||
{
|
{
|
||||||
private static IFusedDesktopManagerService? _instance;
|
private static IFusedDesktopManagerService? _instance;
|
||||||
private static readonly object _lock = new();
|
private static readonly object _lock = new();
|
||||||
|
|
||||||
public static IFusedDesktopManagerService GetOrCreate()
|
public static IFusedDesktopManagerService GetOrCreate()
|
||||||
{
|
{
|
||||||
if (_instance is not null) return _instance;
|
if (_instance is not null) return _instance;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||||
|
|||||||
160
LanMountainDesktop/Services/Update/AppDeploymentLocator.cs
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using LanMountainDesktop.Launcher.Models;
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Update;
|
internal sealed class DeploymentActivator(AppDeploymentLocator deploymentLocator)
|
||||||
|
|
||||||
internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
|
|
||||||
{
|
{
|
||||||
public void Activate(string fromDeployment, string toDeployment)
|
public void Activate(string fromDeployment, string toDeployment)
|
||||||
{
|
{
|
||||||
@@ -13,24 +11,14 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
|
|||||||
var toPartial = Path.Combine(toDeployment, ".partial");
|
var toPartial = Path.Combine(toDeployment, ".partial");
|
||||||
|
|
||||||
File.WriteAllText(toCurrent, string.Empty);
|
File.WriteAllText(toCurrent, string.Empty);
|
||||||
if (File.Exists(toDestroy))
|
if (File.Exists(toDestroy)) File.Delete(toDestroy);
|
||||||
{
|
if (File.Exists(fromCurrent)) File.Delete(fromCurrent);
|
||||||
File.Delete(toDestroy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(fromCurrent))
|
|
||||||
{
|
|
||||||
File.Delete(fromCurrent);
|
|
||||||
}
|
|
||||||
|
|
||||||
File.WriteAllText(fromDestroy, string.Empty);
|
File.WriteAllText(fromDestroy, string.Empty);
|
||||||
if (File.Exists(toPartial))
|
if (File.Exists(toPartial)) File.Delete(toPartial);
|
||||||
{
|
|
||||||
File.Delete(toPartial);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot)
|
public RollbackAttemptResult TryRollbackOnFailure(ApplySnapshotMetadata snapshot)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -45,16 +33,10 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy");
|
var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy");
|
||||||
if (File.Exists(destroyMarker))
|
if (File.Exists(destroyMarker)) File.Delete(destroyMarker);
|
||||||
{
|
|
||||||
File.Delete(destroyMarker);
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current");
|
var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current");
|
||||||
if (!File.Exists(currentMarker))
|
if (!File.Exists(currentMarker)) File.WriteAllText(currentMarker, string.Empty);
|
||||||
{
|
|
||||||
File.WriteAllText(currentMarker, string.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RollbackAttemptResult(true, null);
|
return new RollbackAttemptResult(true, null);
|
||||||
}
|
}
|
||||||
@@ -64,10 +46,7 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RetainDeploymentsForRollback()
|
public void RetainDeploymentsForRollback() => deploymentLocator.CleanupOldDeployments(3);
|
||||||
{
|
|
||||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
|
internal sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
|
||||||
@@ -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()
|
public void Cleanup()
|
||||||
{
|
{
|
||||||
@@ -12,7 +12,8 @@ internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths)
|
|||||||
paths.PlondsFileMapPath,
|
paths.PlondsFileMapPath,
|
||||||
paths.PlondsSignaturePath,
|
paths.PlondsSignaturePath,
|
||||||
paths.PlondsUpdateMetadataPath,
|
paths.PlondsUpdateMetadataPath,
|
||||||
paths.InstallCheckpointPath
|
paths.InstallCheckpointPath,
|
||||||
|
paths.DownloadMarkerPath
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
TryDeleteFile(path);
|
TryDeleteFile(path);
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using System.Text.Json;
|
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))
|
if (!File.Exists(paths.InstallCheckpointPath))
|
||||||
{
|
{
|
||||||
@@ -20,7 +19,7 @@ internal sealed class InstallCheckpointStore(UpdateEnginePaths paths)
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint);
|
return JsonSerializer.Deserialize(text, UpdateApplyJsonContext.Default.ApplyInstallCheckpoint);
|
||||||
}
|
}
|
||||||
catch
|
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()
|
public void Delete()
|
||||||
110
LanMountainDesktop/Services/Update/PlondsApplyModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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 UpdateDirectoryName = "update";
|
||||||
public const string IncomingDirectoryName = "incoming";
|
public const string IncomingDirectoryName = "incoming";
|
||||||
@@ -16,53 +16,34 @@ internal sealed class UpdateEnginePaths
|
|||||||
public const string PlondsObjectsDirectoryName = "objects";
|
public const string PlondsObjectsDirectoryName = "objects";
|
||||||
public const string PublicKeyFileName = "public-key.pem";
|
public const string PublicKeyFileName = "public-key.pem";
|
||||||
|
|
||||||
public UpdateEnginePaths(string appRoot)
|
public PlondsApplyPaths(string launcherRoot)
|
||||||
{
|
{
|
||||||
AppRoot = appRoot;
|
LauncherRoot = launcherRoot;
|
||||||
var resolver = new DataLocationResolver(appRoot);
|
IncomingRoot = UpdatePaths.GetIncomingDirectory(launcherRoot);
|
||||||
LauncherRoot = resolver.ResolveLauncherDataPath();
|
SnapshotsRoot = UpdatePaths.GetSnapshotsDirectory(launcherRoot);
|
||||||
IncomingRoot = Path.Combine(LauncherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
|
||||||
SnapshotsRoot = Path.Combine(LauncherRoot, SnapshotsDirectoryName);
|
|
||||||
InstallCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(appRoot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string AppRoot { get; }
|
|
||||||
|
|
||||||
public string LauncherRoot { get; }
|
public string LauncherRoot { get; }
|
||||||
|
|
||||||
public string IncomingRoot { get; }
|
public string IncomingRoot { get; }
|
||||||
|
|
||||||
public string SnapshotsRoot { get; }
|
public string SnapshotsRoot { get; }
|
||||||
|
public string InstallCheckpointPath => UpdatePaths.GetInstallCheckpointPath(LauncherRoot);
|
||||||
|
|
||||||
public string InstallCheckpointPath { get; }
|
public string ApplyLockPath => UpdatePaths.GetApplyInProgressLockPath(LauncherRoot);
|
||||||
|
public string DeploymentLockPath => UpdatePaths.GetDeploymentLockPath(LauncherRoot);
|
||||||
public string ApplyLockPath => ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(AppRoot);
|
public string DownloadMarkerPath => UpdatePaths.GetDownloadMarkerPath(LauncherRoot);
|
||||||
|
|
||||||
public string DeploymentLockPath => ContractsUpdate.UpdatePaths.GetDeploymentLockPath(AppRoot);
|
|
||||||
|
|
||||||
public string DownloadMarkerPath => ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(AppRoot);
|
|
||||||
|
|
||||||
public string FileMapPath => Path.Combine(IncomingRoot, SignedFileMapName);
|
public string FileMapPath => Path.Combine(IncomingRoot, SignedFileMapName);
|
||||||
|
|
||||||
public string SignaturePath => Path.Combine(IncomingRoot, SignatureFileName);
|
public string SignaturePath => Path.Combine(IncomingRoot, SignatureFileName);
|
||||||
|
|
||||||
public string ArchivePath => Path.Combine(IncomingRoot, ArchiveFileName);
|
public string ArchivePath => Path.Combine(IncomingRoot, ArchiveFileName);
|
||||||
|
|
||||||
public string PlondsFileMapPath => Path.Combine(IncomingRoot, PlondsFileMapName);
|
public string PlondsFileMapPath => Path.Combine(IncomingRoot, PlondsFileMapName);
|
||||||
|
|
||||||
public string PlondsSignaturePath => Path.Combine(IncomingRoot, PlondsSignatureFileName);
|
public string PlondsSignaturePath => Path.Combine(IncomingRoot, PlondsSignatureFileName);
|
||||||
|
|
||||||
public string PlondsUpdateMetadataPath => Path.Combine(IncomingRoot, PlondsUpdateMetadataName);
|
public string PlondsUpdateMetadataPath => Path.Combine(IncomingRoot, PlondsUpdateMetadataName);
|
||||||
|
|
||||||
public string PlondsObjectsRoot => Path.Combine(IncomingRoot, PlondsObjectsDirectoryName);
|
public string PlondsObjectsRoot => Path.Combine(IncomingRoot, PlondsObjectsDirectoryName);
|
||||||
|
|
||||||
public string PublicKeyPath => Path.Combine(LauncherRoot, UpdateDirectoryName, PublicKeyFileName);
|
public string PublicKeyPath => Path.Combine(LauncherRoot, ".Launcher", UpdateDirectoryName, PublicKeyFileName);
|
||||||
|
|
||||||
public string ExtractRoot => Path.Combine(IncomingRoot, "extracted");
|
|
||||||
|
|
||||||
public bool HasPlondsPayload => File.Exists(PlondsFileMapPath) && File.Exists(PlondsSignaturePath);
|
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");
|
public string GetSnapshotPath(string snapshotId) => Path.Combine(SnapshotsRoot, $"{snapshotId}.json");
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Update;
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
internal static class PlondsManifestParser
|
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 })
|
if (fileMap.Files is { Count: > 0 })
|
||||||
{
|
{
|
||||||
files.AddRange(fileMap.Files);
|
files.AddRange(fileMap.Files);
|
||||||
@@ -29,7 +28,7 @@ internal static class PlondsManifestParser
|
|||||||
return files;
|
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))
|
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))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
@@ -74,7 +73,7 @@ internal static class PlondsManifestParser
|
|||||||
var text = File.ReadAllText(path);
|
var text = File.ReadAllText(path);
|
||||||
return string.IsNullOrWhiteSpace(text)
|
return string.IsNullOrWhiteSpace(text)
|
||||||
? null
|
? null
|
||||||
: JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata);
|
: JsonSerializer.Deserialize(text, UpdateApplyJsonContext.Default.ApplyPlondsUpdateMetadata);
|
||||||
}
|
}
|
||||||
catch
|
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(
|
return FirstNonEmpty(
|
||||||
metadata?.FromVersion,
|
metadata?.FromVersion,
|
||||||
@@ -91,7 +90,7 @@ internal static class PlondsManifestParser
|
|||||||
TryGetMetadataValue(fileMap.Metadata, "sourceVersion"));
|
TryGetMetadataValue(fileMap.Metadata, "sourceVersion"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? ResolveTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
|
public static string? ResolveTargetVersion(ApplyPlondsFileMap fileMap, ApplyPlondsUpdateMetadata? metadata)
|
||||||
{
|
{
|
||||||
return FirstNonEmpty(
|
return FirstNonEmpty(
|
||||||
metadata?.ToVersion,
|
metadata?.ToVersion,
|
||||||
@@ -101,7 +100,7 @@ internal static class PlondsManifestParser
|
|||||||
TryGetMetadataValue(fileMap.Metadata, "targetVersion"));
|
TryGetMetadataValue(fileMap.Metadata, "targetVersion"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected)
|
public static bool TryGetExpectedSha512(ApplyPlondsFileEntry file, out byte[] expected)
|
||||||
{
|
{
|
||||||
expected = [];
|
expected = [];
|
||||||
if (file.Sha512Bytes is { Length: > 0 })
|
if (file.Sha512Bytes is { Length: > 0 })
|
||||||
@@ -134,7 +133,7 @@ internal static class PlondsManifestParser
|
|||||||
return UpdateHash.TryParseHashBytes(file.Sha512Base64, out expected);
|
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 = [];
|
expected = [];
|
||||||
if (file.Hash is null)
|
if (file.Hash is null)
|
||||||
@@ -157,7 +156,7 @@ internal static class PlondsManifestParser
|
|||||||
return UpdateHash.TryParseHashBytes(file.Hash.Value, out expected);
|
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)
|
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)
|
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");
|
var path = ReadStringIgnoreCase(node, "path");
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
{
|
{
|
||||||
@@ -240,7 +239,7 @@ internal static class PlondsManifestParser
|
|||||||
|
|
||||||
var archiveSha512 = ReadByteArrayIgnoreCase(node, "archivesha512");
|
var archiveSha512 = ReadByteArrayIgnoreCase(node, "archivesha512");
|
||||||
var archiveSha512Text = ReadStringIgnoreCase(node, "archivesha512");
|
var archiveSha512Text = ReadStringIgnoreCase(node, "archivesha512");
|
||||||
entry = new PlondsFileEntry
|
entry = new ApplyPlondsFileEntry
|
||||||
{
|
{
|
||||||
Path = path,
|
Path = path,
|
||||||
Action = FirstNonEmpty(ReadStringIgnoreCase(node, "action"), "replace"),
|
Action = FirstNonEmpty(ReadStringIgnoreCase(node, "action"), "replace"),
|
||||||
@@ -257,7 +256,7 @@ internal static class PlondsManifestParser
|
|||||||
|
|
||||||
if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text))
|
if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text))
|
||||||
{
|
{
|
||||||
entry.Hash = new PlondsHashDescriptor
|
entry.Hash = new ApplyPlondsHashDescriptor
|
||||||
{
|
{
|
||||||
Algorithm = "sha512",
|
Algorithm = "sha512",
|
||||||
Bytes = archiveSha512,
|
Bytes = archiveSha512,
|
||||||
@@ -268,7 +267,7 @@ internal static class PlondsManifestParser
|
|||||||
}
|
}
|
||||||
else if (TryGetPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object)
|
else if (TryGetPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
entry.Hash = new PlondsHashDescriptor
|
entry.Hash = new ApplyPlondsHashDescriptor
|
||||||
{
|
{
|
||||||
Algorithm = ReadStringIgnoreCase(hashNode, "algorithm"),
|
Algorithm = ReadStringIgnoreCase(hashNode, "algorithm"),
|
||||||
Value = ReadStringIgnoreCase(hashNode, "value"),
|
Value = ReadStringIgnoreCase(hashNode, "value"),
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using System.IO.Compression;
|
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>();
|
var candidates = new List<string>();
|
||||||
AddPathCandidates(candidates, file.ObjectPath);
|
AddPathCandidates(candidates, file.ObjectPath);
|
||||||
@@ -18,14 +17,14 @@ internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths)
|
|||||||
PlondsManifestParser.TryGetExpectedSha512(file, out expectedSha512))
|
PlondsManifestParser.TryGetExpectedSha512(file, out expectedSha512))
|
||||||
{
|
{
|
||||||
var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant();
|
var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant();
|
||||||
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex));
|
AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex));
|
||||||
if (hashHex.Length > 2)
|
if (hashHex.Length > 2)
|
||||||
{
|
{
|
||||||
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex));
|
AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex));
|
||||||
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..]));
|
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))
|
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);
|
normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||||
candidates.Add(normalized);
|
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);
|
var fileName = Path.GetFileName(normalized);
|
||||||
if (!string.IsNullOrWhiteSpace(fileName))
|
if (!string.IsNullOrWhiteSpace(fileName))
|
||||||
{
|
{
|
||||||
candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, fileName));
|
candidates.Add(Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,54 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
using ContractsUpdate = 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(
|
internal sealed class PlondsUpdateApplier(
|
||||||
DeploymentLocator deploymentLocator,
|
AppDeploymentLocator deploymentLocator,
|
||||||
UpdateEnginePaths paths,
|
PlondsApplyPaths paths,
|
||||||
UpdateSignatureVerifier signatureVerifier,
|
UpdateSignatureVerifier signatureVerifier,
|
||||||
IUpdateProgressReporter progressReporter,
|
IUpdateProgressReporter progressReporter,
|
||||||
UpdateSnapshotStore snapshotStore,
|
UpdateSnapshotStore snapshotStore,
|
||||||
InstallCheckpointStore checkpointStore,
|
ApplyInstallCheckpointStore checkpointStore,
|
||||||
DeploymentActivator deploymentActivator,
|
DeploymentActivator deploymentActivator,
|
||||||
IncomingArtifactsCleaner incomingCleaner,
|
IncomingArtifactsCleaner incomingCleaner,
|
||||||
PlondsPayloadResolver payloadResolver)
|
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));
|
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0));
|
||||||
var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, UpdateEnginePaths.PlondsSignatureFileName);
|
var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, PlondsApplyPaths.PlondsSignatureFileName);
|
||||||
if (!verifyResult.Success)
|
if (!verifyResult.Success)
|
||||||
{
|
{
|
||||||
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
|
progressReporter.ReportComplete(new InstallCompleteReport(false, null, null, verifyResult.Message, false));
|
||||||
return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message);
|
return ApplyUpdateResults.Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileMapText = await File.ReadAllTextAsync(paths.PlondsFileMapPath).ConfigureAwait(false);
|
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);
|
var fileEntries = PlondsManifestParser.CollectFileEntries(fileMap);
|
||||||
if (fileEntries.Count == 0)
|
if (fileEntries.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -35,29 +57,23 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
|
|
||||||
if (fileEntries.Count == 0)
|
if (fileEntries.Count == 0)
|
||||||
{
|
{
|
||||||
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
|
progressReporter.ReportComplete(new InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
|
||||||
return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
|
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 currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
|
||||||
var currentVersion = deploymentLocator.GetCurrentVersion();
|
var currentVersion = deploymentLocator.GetCurrentVersion();
|
||||||
var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion;
|
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) &&
|
if (!string.IsNullOrWhiteSpace(expectedSourceVersion) &&
|
||||||
!string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase))
|
!string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return UpdateEngineResults.Failed(
|
return ApplyUpdateResults.Failed("update.apply", "version_mismatch", $"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
|
||||||
"update.apply",
|
|
||||||
"version_mismatch",
|
|
||||||
$"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, pdcMetadata);
|
var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, plondsMetadata);
|
||||||
if (string.IsNullOrWhiteSpace(targetVersion))
|
if (string.IsNullOrWhiteSpace(targetVersion)) targetVersion = sourceVersion;
|
||||||
{
|
|
||||||
targetVersion = sourceVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment);
|
var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment);
|
||||||
var existingCheckpoint = checkpointStore.Load();
|
var existingCheckpoint = checkpointStore.Load();
|
||||||
@@ -70,29 +86,21 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
|
|
||||||
if (existingCheckpoint is not null && !canResume)
|
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
|
var targetDeployment = canResume ? existingCheckpoint!.TargetDirectory : deploymentLocator.BuildNextDeploymentDirectory(targetVersion!);
|
||||||
? existingCheckpoint!.TargetDirectory
|
|
||||||
: deploymentLocator.BuildNextDeploymentDirectory(targetVersion!);
|
|
||||||
var snapshot = BuildSnapshot(canResume, existingCheckpoint, sourceVersion, targetVersion, currentDeployment, targetDeployment);
|
var snapshot = BuildSnapshot(canResume, existingCheckpoint, sourceVersion, targetVersion, currentDeployment, targetDeployment);
|
||||||
var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId);
|
var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId);
|
||||||
var checkpoint = canResume
|
var checkpoint = canResume ? existingCheckpoint! : BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment);
|
||||||
? existingCheckpoint!
|
|
||||||
: BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
snapshotStore.Save(snapshotPath, snapshot);
|
snapshotStore.Save(snapshotPath, snapshot);
|
||||||
if (!canResume)
|
if (!canResume)
|
||||||
{
|
{
|
||||||
if (Directory.Exists(targetDeployment))
|
if (Directory.Exists(targetDeployment)) Directory.Delete(targetDeployment, true);
|
||||||
{
|
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
|
||||||
Directory.Delete(targetDeployment, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
|
|
||||||
Directory.CreateDirectory(targetDeployment);
|
Directory.CreateDirectory(targetDeployment);
|
||||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||||
}
|
}
|
||||||
@@ -105,14 +113,11 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
{
|
{
|
||||||
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
|
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
|
||||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||||
if (File.Exists(partialMarker))
|
if (File.Exists(partialMarker)) File.Delete(partialMarker);
|
||||||
{
|
|
||||||
File.Delete(partialMarker);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
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);
|
deploymentActivator.Activate(currentDeployment!, targetDeployment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,10 +126,10 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
incomingCleaner.Cleanup();
|
incomingCleaner.Cleanup();
|
||||||
deploymentActivator.RetainDeploymentsForRollback();
|
deploymentActivator.RetainDeploymentsForRollback();
|
||||||
|
|
||||||
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
|
progressReporter.ReportProgress(new InstallProgressReport(InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
|
||||||
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
|
progressReporter.ReportComplete(new InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
|
||||||
|
|
||||||
return new LauncherResult
|
return new ApplyUpdateResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Stage = "update.apply",
|
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++)
|
for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++)
|
||||||
{
|
{
|
||||||
var entry = fileEntries[fileIndex];
|
var entry = fileEntries[fileIndex];
|
||||||
ApplyFileEntry(entry, currentDeployment, targetDeployment);
|
ApplyFileEntry(entry, currentDeployment, targetDeployment);
|
||||||
checkpoint.AppliedCount = fileIndex + 1;
|
checkpoint.AppliedCount = fileIndex + 1;
|
||||||
checkpointStore.Save(checkpoint);
|
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++)
|
for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++)
|
||||||
{
|
{
|
||||||
var entry = fileEntries[verifyIndex];
|
var entry = fileEntries[verifyIndex];
|
||||||
VerifyFileEntry(entry, targetDeployment);
|
VerifyFileEntry(entry, targetDeployment);
|
||||||
checkpoint.VerifiedCount = verifyIndex + 1;
|
checkpoint.VerifiedCount = verifyIndex + 1;
|
||||||
checkpointStore.Save(checkpoint);
|
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 normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path);
|
||||||
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
|
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
|
||||||
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) return;
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
||||||
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
|
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||||
var targetDir = Path.GetDirectoryName(targetPath);
|
var targetDir = Path.GetDirectoryName(targetPath);
|
||||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
if (!string.IsNullOrWhiteSpace(targetDir)) Directory.CreateDirectory(targetDir);
|
||||||
{
|
|
||||||
Directory.CreateDirectory(targetDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -200,47 +199,30 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
ApplyUnixFileModeIfPresent(targetPath, file);
|
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))
|
if (string.IsNullOrWhiteSpace(currentDeployment)) throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available.");
|
||||||
{
|
|
||||||
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
|
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
|
||||||
UpdatePathGuard.EnsurePathWithinRoot(sourcePath, currentDeployment);
|
UpdatePathGuard.EnsurePathWithinRoot(sourcePath, currentDeployment);
|
||||||
if (!File.Exists(sourcePath))
|
if (!File.Exists(sourcePath)) throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
|
||||||
{
|
|
||||||
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||||
ApplyUnixFileModeIfPresent(targetPath, file);
|
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!;
|
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
|
||||||
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) return;
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var targetPath = Path.Combine(targetDeployment, UpdatePathGuard.NormalizeRelativePath(file.Path));
|
var targetPath = Path.Combine(targetDeployment, UpdatePathGuard.NormalizeRelativePath(file.Path));
|
||||||
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
|
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||||
if (!File.Exists(targetPath))
|
if (!File.Exists(targetPath)) throw new FileNotFoundException($"Expected target file was not created: {file.Path}");
|
||||||
{
|
|
||||||
throw new FileNotFoundException($"Expected target file was not created: {file.Path}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PlondsManifestParser.TryGetExpectedSha512(file, out var expectedSha512))
|
if (PlondsManifestParser.TryGetExpectedSha512(file, out var expectedSha512))
|
||||||
{
|
{
|
||||||
var actualSha512 = UpdateHash.ComputeSha512(targetPath);
|
var actualSha512 = UpdateHash.ComputeSha512(targetPath);
|
||||||
if (!actualSha512.AsSpan().SequenceEqual(expectedSha512))
|
if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'.");
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,29 +230,19 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
{
|
{
|
||||||
var expectedSha256 = UpdateHash.NormalizeHashText(file.Sha256);
|
var expectedSha256 = UpdateHash.NormalizeHashText(file.Sha256);
|
||||||
var actualSha256 = UpdateHash.ComputeSha256Hex(targetPath);
|
var actualSha256 = UpdateHash.ComputeSha256Hex(targetPath);
|
||||||
if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'.");
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private LauncherResult HandleFailure(
|
private ApplyUpdateResult HandleFailure(Exception ex, bool isInitialDeployment, string targetDeployment, ApplySnapshotMetadata snapshot, string snapshotPath, string sourceVersion, string targetVersion)
|
||||||
Exception ex,
|
|
||||||
bool isInitialDeployment,
|
|
||||||
string targetDeployment,
|
|
||||||
SnapshotMetadata snapshot,
|
|
||||||
string snapshotPath,
|
|
||||||
string sourceVersion,
|
|
||||||
string targetVersion)
|
|
||||||
{
|
{
|
||||||
if (isInitialDeployment)
|
if (isInitialDeployment)
|
||||||
{
|
{
|
||||||
TryDeleteDirectory(targetDeployment);
|
TryDeleteDirectory(targetDeployment);
|
||||||
snapshot.Status = "failed";
|
snapshot.Status = "failed";
|
||||||
snapshotStore.Save(snapshotPath, snapshot);
|
snapshotStore.Save(snapshotPath, snapshot);
|
||||||
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false));
|
progressReporter.ReportComplete(new InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false));
|
||||||
return new LauncherResult
|
return new ApplyUpdateResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Stage = "update.apply",
|
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);
|
var rollbackResult = deploymentActivator.TryRollbackOnFailure(snapshot);
|
||||||
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
|
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
|
||||||
snapshotStore.Save(snapshotPath, snapshot);
|
snapshotStore.Save(snapshotPath, snapshot);
|
||||||
var errorMessage = rollbackResult.Success
|
|
||||||
? ex.Message
|
var errorMessage = rollbackResult.Success ? ex.Message : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
|
||||||
: $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
|
progressReporter.ReportComplete(new InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success));
|
||||||
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success));
|
|
||||||
return new LauncherResult
|
return new ApplyUpdateResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Stage = "update.apply",
|
Stage = "update.apply",
|
||||||
Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
|
Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
|
||||||
Message = rollbackResult.Success
|
Message = rollbackResult.Success ? "Failed to apply PLONDS update. Rolled back to previous version." : "Failed to apply PLONDS update and rollback failed.",
|
||||||
? "Failed to apply PLONDS update. Rolled back to previous version."
|
|
||||||
: "Failed to apply PLONDS update and rollback failed.",
|
|
||||||
ErrorMessage = errorMessage,
|
ErrorMessage = errorMessage,
|
||||||
CurrentVersion = sourceVersion,
|
CurrentVersion = sourceVersion,
|
||||||
RolledBackTo = rollbackResult.Success ? sourceVersion : null
|
RolledBackTo = rollbackResult.Success ? sourceVersion : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SnapshotMetadata BuildSnapshot(
|
private static ApplySnapshotMetadata BuildSnapshot(bool canResume, ApplyInstallCheckpoint? existingCheckpoint, string sourceVersion, string targetVersion, string? currentDeployment, string targetDeployment) =>
|
||||||
bool canResume,
|
|
||||||
InstallCheckpoint? existingCheckpoint,
|
|
||||||
string sourceVersion,
|
|
||||||
string targetVersion,
|
|
||||||
string? currentDeployment,
|
|
||||||
string targetDeployment) =>
|
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
|
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
|
||||||
@@ -322,13 +286,7 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
Status = "pending"
|
Status = "pending"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static InstallCheckpoint BuildCheckpoint(
|
private static ApplyInstallCheckpoint BuildCheckpoint(ApplySnapshotMetadata snapshot, string sourceVersion, string targetVersion, string? currentDeployment, string targetDeployment, bool isInitialDeployment) =>
|
||||||
SnapshotMetadata snapshot,
|
|
||||||
string sourceVersion,
|
|
||||||
string targetVersion,
|
|
||||||
string? currentDeployment,
|
|
||||||
string targetDeployment,
|
|
||||||
bool isInitialDeployment) =>
|
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
SnapshotId = snapshot.SnapshotId,
|
SnapshotId = snapshot.SnapshotId,
|
||||||
@@ -339,15 +297,9 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
IsInitialDeployment = isInitialDeployment
|
IsInitialDeployment = isInitialDeployment
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file)
|
private static void ApplyUnixFileModeIfPresent(string targetPath, ApplyPlondsFileEntry file)
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows() ||
|
if (OperatingSystem.IsWindows() || !file.Metadata.TryGetValue("unixFileMode", out var rawMode) || string.IsNullOrWhiteSpace(rawMode)) return;
|
||||||
!file.Metadata.TryGetValue("unixFileMode", out var rawMode) ||
|
|
||||||
string.IsNullOrWhiteSpace(rawMode))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var modeValue = Convert.ToInt32(rawMode.Trim(), 8);
|
var modeValue = Convert.ToInt32(rawMode.Trim(), 8);
|
||||||
@@ -362,10 +314,7 @@ internal sealed class PlondsUpdateApplier(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (Directory.Exists(path))
|
if (Directory.Exists(path)) Directory.Delete(path, true);
|
||||||
{
|
|
||||||
Directory.Delete(path, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -1,42 +1,40 @@
|
|||||||
using LanMountainDesktop.Launcher.Models;
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Update;
|
|
||||||
|
|
||||||
internal sealed class RollbackStrategy(
|
internal sealed class RollbackStrategy(
|
||||||
DeploymentLocator deploymentLocator,
|
AppDeploymentLocator deploymentLocator,
|
||||||
UpdateSnapshotStore snapshotStore,
|
UpdateSnapshotStore snapshotStore,
|
||||||
DeploymentActivator deploymentActivator)
|
DeploymentActivator deploymentActivator)
|
||||||
{
|
{
|
||||||
public LauncherResult RollbackLatest()
|
public ApplyUpdateResult RollbackLatest()
|
||||||
{
|
{
|
||||||
var latest = snapshotStore.LoadLatest();
|
var latest = snapshotStore.LoadLatest();
|
||||||
if (latest is null)
|
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;
|
var (snapshotPath, snapshot) = latest.Value;
|
||||||
if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
|
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))
|
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();
|
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
|
||||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
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);
|
deploymentActivator.Activate(currentDeployment, snapshot.SourceDirectory);
|
||||||
snapshot.Status = "manual_rollback";
|
snapshot.Status = "manual_rollback";
|
||||||
snapshotStore.Save(snapshotPath, snapshot);
|
snapshotStore.Save(snapshotPath, snapshot);
|
||||||
|
|
||||||
return new LauncherResult
|
return new ApplyUpdateResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Stage = "update.rollback",
|
Stage = "update.rollback",
|
||||||
13
LanMountainDesktop/Services/Update/UpdateApplyJsonContext.cs
Normal 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;
|
||||||
16
LanMountainDesktop/Services/Update/UpdateApplyResults.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Update;
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
internal static class UpdateHash
|
internal static class UpdateHash
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@ internal sealed class UpdateInstallGateway
|
|||||||
return new InstallResult(false, lockError, false, lockErrorCode);
|
return new InstallResult(false, lockError, false, lockErrorCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
|
if (payloadKind is UpdatePayloadKind.DeltaPlonds)
|
||||||
{
|
{
|
||||||
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
|
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
|
||||||
if (!launched)
|
if (!launched)
|
||||||
@@ -108,7 +108,7 @@ internal sealed class UpdateInstallGateway
|
|||||||
return false;
|
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))
|
if (!string.Equals(deploymentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
errorCode = "lock_conflict";
|
errorCode = "lock_conflict";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Update;
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
internal static class UpdatePathGuard
|
internal static class UpdatePathGuard
|
||||||
{
|
{
|
||||||
16
LanMountainDesktop/Services/Update/UpdateRollbackGateway.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Security.Cryptography;
|
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)
|
public (bool Success, string Message) Verify(string payloadPath, string signaturePath, string signatureName)
|
||||||
{
|
{
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
using System.Text.Json;
|
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 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))
|
if (!Directory.Exists(paths.SnapshotsRoot))
|
||||||
{
|
{
|
||||||
@@ -28,7 +27,7 @@ internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths)
|
|||||||
return null;
|
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);
|
return snapshot is null ? null : (snapshotPath, snapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,11 +15,7 @@ public static class UpdateSettingsValues
|
|||||||
|
|
||||||
// NOTE: keep constant name for compatibility with existing call sites.
|
// NOTE: keep constant name for compatibility with existing call sites.
|
||||||
public const string DownloadSourcePlonds = "plonds-api";
|
public const string DownloadSourcePlonds = "plonds-api";
|
||||||
public const string DownloadSourcePdc = DownloadSourcePlonds;
|
|
||||||
public const string DownloadSourceStcn = 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 DownloadSourceGitHub = "github";
|
||||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||||
public const string PlondsStaticBaseUrlEnvironmentVariable = "LANMOUNTAIN_UPDATE_BASE_URL";
|
public const string PlondsStaticBaseUrlEnvironmentVariable = "LANMOUNTAIN_UPDATE_BASE_URL";
|
||||||
@@ -62,12 +58,12 @@ public static class UpdateSettingsValues
|
|||||||
|
|
||||||
public static string NormalizeDownloadSource(string? value)
|
public static string NormalizeDownloadSource(string? value)
|
||||||
{
|
{
|
||||||
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value, "pdc", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return DownloadSourcePlonds;
|
return DownloadSourcePlonds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(value, LegacyDownloadSourceStcn, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value, "stcn", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return DownloadSourcePlonds;
|
return DownloadSourcePlonds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
|||||||
[ObservableProperty] private bool _forceReinstall;
|
[ObservableProperty] private bool _forceReinstall;
|
||||||
|
|
||||||
[ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
[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 string _selectedUpdateModeValue = UpdateSettingsValues.ModeSilentDownload;
|
||||||
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
|
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
|||||||
LatestVersionText = report.LatestVersion ?? string.Empty;
|
LatestVersionText = report.LatestVersion ?? string.Empty;
|
||||||
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
|
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
|
||||||
UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
|
UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
|
||||||
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
|
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds;
|
||||||
StatusMessage = report.LatestVersion is null
|
StatusMessage = report.LatestVersion is null
|
||||||
? GetUpdateAvailableStatusText(string.Empty)
|
? GetUpdateAvailableStatusText(string.Empty)
|
||||||
: string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), report.LatestVersion);
|
: 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
|
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"),
|
UpdatePayloadKind.FullInstaller => L("settings.update.type_reinstall", "Reinstall"),
|
||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
|
|||||||