Compare commits

...

46 Commits

Author SHA1 Message Date
lincube
631dc7795a Normalize release artifacts before publishing 2026-04-21 21:17:52 +08:00
lincube
001a42a97f Fix Windows installer script path in release workflow 2026-04-21 20:18:12 +08:00
lincube
8a75bc818a Rebuild release pipeline around PLONDS and DDSS 2026-04-21 19:26:59 +08:00
lincube
8568fdf16b ci.plonds 2026-04-21 16:12:47 +08:00
lincube
d31aa90b9c ci: handle empty plonds baselines safely 2026-04-21 08:39:07 +08:00
lincube
0878bcab5a ci: avoid multipart uploads to rainyun s3 2026-04-21 08:27:06 +08:00
lincube
4d5bea0c46 ci: relax aws checksum mode for rainyun s3 2026-04-21 08:20:41 +08:00
lincube
8323b8cb61 ci: validate signing key and quiet missing baselines 2026-04-21 00:46:57 +08:00
lincube
82f1e77393 ci: fix plonds s3 probe and signing fallback 2026-04-21 00:11:17 +08:00
lincube
a31ae3cd58 feat.Penguin Logistics Online Network Distribution System 2026-04-20 23:28:11 +08:00
lincube
3f927c41c8 ci: fix pdcc publish workdir bootstrap 2026-04-20 21:29:45 +08:00
lincube
44725d7ff3 ci: add pdcc publish heartbeat and timeout 2026-04-20 20:47:48 +08:00
lincube
e623aef350 ci: publish pdcc subchannels in one pass 2026-04-20 19:38:54 +08:00
lincube
63d5165860 ci: harden local pdc mock transport handling 2026-04-20 18:40:19 +08:00
lincube
6d513096d3 ci: pin pdcc client version separately from app version 2026-04-20 18:16:17 +08:00
lincube
f487a32149 ci: wire aws cli credentials for rainyun s3 2026-04-20 18:05:32 +08:00
lincube
a553f2f7aa Update App.axaml.cs 2026-04-20 17:42:16 +08:00
lincube
f03b74ff32 ci: fix pdcc variable mapping and pdc signing prechecks 2026-04-20 17:30:48 +08:00
lincube
bc1520a5d8 ci: make local pdc mock diff return empty for fast fallback 2026-04-20 16:41:34 +08:00
lincube
46341edbea ci: package pdcc subchannels with generated filemap and changelog 2026-04-20 15:39:55 +08:00
lincube
f421f574e1 ci: decouple pdcc installer version from publish config version 2026-04-20 15:28:11 +08:00
lincube
8ea8c684a9 ci: set pdcc version variable from release version 2026-04-20 15:19:16 +08:00
lincube
b411d91b35 ci: create pdcc publish root before invoking client 2026-04-20 15:07:14 +08:00
lincube
a2f0af9031 ci: ensure pdcc signing passphrase env is always set 2026-04-20 14:56:27 +08:00
lincube
5861d73964 ci: fallback pdcc signing key to update private key 2026-04-20 14:44:00 +08:00
lincube
64975d5752 ci: fix pdc mock process log redirection 2026-04-20 14:34:16 +08:00
lincube
8c58b1c43e ci: add local pdc mock fallback for release publish 2026-04-20 14:25:17 +08:00
lincube
e82c5d41fd set GH_TOKEN for PDCC installer step 2026-04-20 13:18:32 +08:00
lincube
8447910fee relax publish-pdc precheck to require S3 only 2026-04-20 13:09:13 +08:00
lincube
81e0081721 fix release workflow env key collisions 2026-04-20 12:58:19 +08:00
lincube
fb21bcd8ec refactor update backend to host-managed PDC pipeline 2026-04-20 12:55:19 +08:00
lincube
62e7d96fe7 fix: compare signing keys by SPKI instead of PEM text 2026-04-20 09:15:08 +08:00
lincube
c5ef418bd9 fix: rotate launcher public key to match ci signing secret 2026-04-20 09:05:34 +08:00
lincube
1e6b61db85 fix: normalize PEM line endings in signing key validation 2026-04-20 08:55:45 +08:00
lincube
48ce93b68e fix: sync launcher public key with update signing secret 2026-04-20 08:45:53 +08:00
lincube
cddebbcf5a fix: restore stable launcher update public key 2026-04-20 08:33:14 +08:00
lincube
24b361b5b9 chore: rotate launcher update public key for pdc signing 2026-04-20 08:20:56 +08:00
lincube
833c69305b fix: make delta pack generation robust for empty diffs and linux paths 2026-04-20 08:07:58 +08:00
lincube
858612fa8e fix: make optional s3 upload step workflow-parse safe 2026-04-20 07:55:56 +08:00
lincube
f6a6f97e0b chore: migrate release pipeline to signed filemap and wire rainyun s3 2026-04-20 07:48:53 +08:00
lincube
02547eeea6 feat.引入velopack,不好,是rust(至少内存很安全了。 2026-04-19 20:11:16 +08:00
lincube
8e39ea864f fix.GitHub Action工作流怎么天天出问题 2026-04-19 19:33:45 +08:00
lincube
6343164b24 fix.修ci,修融合桌面,修启动器 2026-04-19 17:02:53 +08:00
lincube
8e21364eed changed.velopack,试试rust 2026-04-19 12:36:14 +08:00
lincube
4f9feafbbe fix.继续修ci,ci怎么天天炸 2026-04-19 02:12:34 +08:00
lincube
9cf3a15c89 fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。 2026-04-18 23:36:31 +08:00
105 changed files with 8243 additions and 1178 deletions

View File

@@ -32,6 +32,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -68,10 +69,18 @@ jobs:
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
# Ubuntu 24.04+ moved several packages to t64 names.
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
# Prefer modern WebKit package, fallback for older images.
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -98,10 +107,14 @@ jobs:
fetch-depth: 0
submodules: recursive
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -132,6 +145,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Pack SDK and template packages
shell: pwsh

View File

@@ -25,12 +25,13 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event.pull_request.head.sha }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}

166
.github/workflows/ddss-publish.yml vendored Normal file
View File

@@ -0,0 +1,166 @@
name: DDSS
on:
workflow_run:
workflows:
- PLONDS
types:
- completed
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
env:
DOTNET_VERSION: '10.0.x'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "$RAW_TAG" == v* ]]; then
TAG="$RAW_TAG"
else
TAG="v$RAW_TAG"
fi
else
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$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: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p release-assets
gh release download "$RELEASE_TAG" -D release-assets
find release-assets -maxdepth 1 -type f | sort
- name: Upload release assets to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
aws --version
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
if [[ "$existing_sha" == "$sha256" ]]; then
echo "Skip existing asset: $name"
continue
fi
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done
- name: Build DDSS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p ddss-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-ddss \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir ddss-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Upload DDSS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
- name: Upload DDSS manifest to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done

235
.github/workflows/plonds-build.yml vendored Normal file
View File

@@ -0,0 +1,235 @@
name: PLONDS
on:
release:
types:
- published
- prereleased
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"
- 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
)
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
- 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

View File

@@ -30,14 +30,22 @@ jobs:
informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
release_channel: ${{ steps.version.outputs.release_channel }}
steps:
- name: Checkout repository metadata
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info
id: version
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
TAG="${GITHUB_REF#refs/tags/}"
CHECKOUT_REF="${GITHUB_REF}"
IS_PRERELEASE="false"
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
@@ -47,19 +55,40 @@ jobs:
else
TAG="v${RAW_TAG}"
fi
CHECKOUT_REF="${GITHUB_SHA}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
CHECKOUT_REF="refs/tags/${TAG}"
else
CHECKOUT_REF="${GITHUB_SHA}"
fi
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
IS_PRERELEASE="true"
else
IS_PRERELEASE="false"
fi
fi
VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
VERSION_PARTS+=("0")
done
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
if [[ "${IS_PRERELEASE}" == "true" ]]; then
RELEASE_CHANNEL="preview"
else
RELEASE_CHANNEL="stable"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT"
echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT"
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT"
build-windows:
needs: prepare
@@ -88,6 +117,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -106,9 +136,6 @@ jobs:
$arch = "${{ matrix.arch }}"
$launcherPublishDir = "publish/launcher-win-$arch"
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT 单文件发布
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
@@ -119,27 +146,16 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-p:DebugType=none `
-p:DebugSymbols=false
-p:DebugSymbols=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed"
exit 1
}
# 显示发布结果
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
$size = [Math]::Round($exeFile.Length / 1MB, 2)
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
}
# 清理不必要的文件AOT 单文件应该只有一个 exe
$files = Get-ChildItem -Path $launcherPublishDir -File
if ($files.Count -gt 1) {
Write-Host "Warning: Expected single file but found $($files.Count) files"
$files | ForEach-Object { Write-Host " - $($_.Name)" }
}
shell: pwsh
- name: Publish Main App
@@ -177,9 +193,6 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
}
Write-Host "Published to: $publishDir"
Write-Host "Self-contained: $selfContained"
shell: pwsh
- name: Restructure for Launcher
@@ -190,30 +203,18 @@ jobs:
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$launcherPublishDir = "publish/launcher-win-$arch"
$appDir = "app-$version"
Write-Host "Restructuring for Launcher mode..."
Write-Host "Version: $version"
Write-Host "Publish dir: $publishDir"
$newStructure = "publish-launcher/windows-$arch"
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
$appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force
$launcherSource = $launcherPublishDir
if (Test-Path $launcherSource) {
Write-Host "Copying Launcher to root..."
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
} else {
Write-Warning "Launcher publish dir not found: $launcherSource"
if (Test-Path $launcherPublishDir) {
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
}
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
Write-Host "New directory structure:"
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -Path $newStructure -Destination $publishDir -Force
@@ -229,60 +230,31 @@ jobs:
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$suffix = "${{ matrix.suffix }}"
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$outputDir = "build-installer"
if (-not (Test-Path -Path $publishDir)) {
Write-Error "Publish directory not found: $publishDir"
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
exit 1
}
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
if (-not (Test-Path -Path $installerScript)) {
Write-Error "Installer script not found: $installerScript"
exit 1
}
$isccPath = $null
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($isccCommand) {
$isccPath = $isccCommand.Source
}
$candidatePaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\bin\ISCC.exe",
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
)
) | Where-Object { $_ -and (Test-Path $_) }
$isccPath = $candidatePaths | Select-Object -First 1
if (-not $isccPath) {
foreach ($candidate in $candidatePaths) {
if ($candidate -and (Test-Path -Path $candidate)) {
$isccPath = $candidate
break
}
}
}
if (-not $isccPath) {
Write-Host "ISCC.exe was not found in PATH or known locations."
Write-Host "Checked locations:"
$candidatePaths | ForEach-Object { Write-Host " - $_" }
Write-Host "Chocolatey bin listing (if exists):"
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
Write-Error "Inno Setup compiler not found."
exit 1
}
Write-Host "Found Inno Setup at: $isccPath"
Write-Host "Building installer for Windows $arch with version $version..."
if (-not (Test-Path $installerScript)) {
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
exit 1
}
$publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path
@@ -298,8 +270,6 @@ jobs:
$installerScript
)
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
& $isccPath @compileArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
@@ -311,186 +281,53 @@ jobs:
Write-Error "Failed to create installer"
exit 1
}
Write-Host "Successfully created: $($installerFile.Name)"
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh
- name: Create App Package
if: matrix.self_contained == true && matrix.arch == 'x64'
- name: Package Payload Zip
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# 创建 app-{version}-win-{arch}.zip 供后续版本作为旧版本对比
$appZipPath = Join-Path $outputDir "app-$version-win-$arch.zip"
Write-Host "Creating app-$version-win-$arch.zip..."
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal
$sizeMB = [Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)
Write-Host "Created app-$version-win-$arch.zip: $sizeMB MB"
shell: pwsh
- name: Generate Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Determine previous version and download its app package for diff ---
$previousVersion = $null
$previousAppPath = $null
try {
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
if ($previousRelease) {
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
Write-Host "Previous release version: $previousVersion"
# 下载旧版本的 app-{version}-win-{arch}.zip
$prevAppZip = $previousRelease.assets | Where-Object { $_.name -eq "app-$previousVersion-win-$arch.zip" } | Select-Object -First 1
if ($prevAppZip) {
Write-Host "Found app-$previousVersion-win-$arch.zip in previous release - downloading for diff..."
$prevAppZipDest = Join-Path $outputDir "prev-app.zip"
Invoke-WebRequest -Uri $prevAppZip.browser_download_url -OutFile $prevAppZipDest -Headers $headers
# 解压 app-{version}.zip
$previousAppPath = Join-Path $outputDir "prev-app"
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
Expand-Archive -Path $prevAppZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevAppZipDest -Force -ErrorAction SilentlyContinue
if ($previousAppPath -and (Test-Path $previousAppPath)) {
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
}
} else {
Write-Host "No app-$previousVersion-win-$arch.zip found in previous release - will generate full package"
Write-Host "This is expected for the first release after this fix."
}
}
} catch {
Write-Host "Could not fetch previous release: $_"
}
# --- Generate delta package using the script ---
if ($previousAppPath -and (Test-Path $previousAppPath) -and $previousVersion) {
Write-Host "Generating delta package from $previousVersion to $version..."
& $scriptPath `
-PreviousVersion $previousVersion `
-CurrentVersion $version `
-PreviousDir $previousAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
} else {
Write-Host "No previous version available - generating full package..."
# Generate a "full" delta package (all files as "add")
& $scriptPath `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
}
# Clean up previous version extraction
if ($previousAppPath -and (Test-Path $previousAppPath)) {
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Display results
$updateZipPath = Join-Path $outputDir "update.zip"
if (Test-Path $updateZipPath) {
$sizeMB = [Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)
Write-Host "Created update.zip: $sizeMB MB"
}
shell: pwsh
- name: Sign File Map
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$outputDir = "delta-output"
$filesJsonPath = Join-Path $outputDir "files.json"
$signaturePath = Join-Path $outputDir "files.json.sig"
if (-not (Test-Path $filesJsonPath)) {
Write-Error "files.json not found at $filesJsonPath"
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
if (-not (Test-Path $payloadRoot)) {
Write-Error "Payload root not found: $payloadRoot"
exit 1
}
$privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
exit 0
$stageDir = Join-Path $PWD "payload-stage/windows-$arch"
$releaseDir = Join-Path $PWD "release-assets"
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
return
}
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
$destinationDir = Split-Path -Path $destination -Parent
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
}
Copy-Item -Path $_.FullName -Destination $destination -Force
}
$privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
using System;
using System.IO;
using System.Security.Cryptography;
public class RsaSigner {
public static void Sign(string jsonPath, string keyPath, string sigPath) {
var jsonBytes = File.ReadAllBytes(jsonPath);
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(keyPath));
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
}
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
if (Test-Path $payloadZip) {
Remove-Item $payloadZip -Force
}
"@
[RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
Remove-Item -Path $privateKeyPath -Force
Write-Host "Signed files.json -> files.json.sig"
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
shell: pwsh
- name: Upload Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
- name: Upload Release Assets
uses: actions/upload-artifact@v4
with:
name: release-delta-windows-x64
name: release-windows-${{ matrix.arch }}
path: |
delta-output/files.json
delta-output/files.json.sig
delta-output/update.zip
delta-output/app-*.zip
if-no-files-found: error
retention-days: 90
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
path: build-installer/*.exe
release-assets/files-windows-${{ matrix.arch }}.zip
build-installer/*.exe
if-no-files-found: error
retention-days: 30
@@ -515,12 +352,17 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
clang zlib1g-dev zip rsync
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -535,8 +377,6 @@ jobs:
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for Linux x64..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-linux-x64 \
@@ -547,15 +387,11 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-linux-x64"
ls -lh ./publish/launcher-linux-x64/
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Main App
run: |
@@ -582,25 +418,15 @@ jobs:
appDir="app-$version"
launcherDir="publish/launcher-linux-x64"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir"
if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi
touch "$publishDir/$appDir/.current"
echo "New directory structure:"
find "$publishDir" -maxdepth 2 | head -50
rm -rf "$launcherDir"
- name: Package as DEB
@@ -613,12 +439,6 @@ jobs:
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
mkdir -p "build-deb/DEBIAN"
mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications"
@@ -627,20 +447,6 @@ jobs:
cp -r "$source"/* "build-deb/usr/local/bin/"
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
echo "DEB package contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: DEB package is empty after copy"
exit 1
fi
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
echo "Error: Linux desktop resources are missing"
ls -la "LanMountainDesktop/packaging/linux" || true
exit 1
fi
sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
@@ -664,9 +470,9 @@ jobs:
printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version"
printf '%s\n' "Architecture: $arch"
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
printf '%s\n' "Description: LanMountain Desktop Application"
printf '%s\n' " A desktop application for LanMountain."
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
printf '%s\n' 'Description: LanMountain Desktop Application'
printf '%s\n' ' A desktop application for LanMountain.'
} > "build-deb/DEBIAN/control"
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
@@ -675,26 +481,49 @@ jobs:
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst"
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
ls -lh "${package_name}_${package_version}_${arch}.deb"
else
echo "Error: Failed to build DEB package"
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
- name: Package Payload Zip
run: |
version="${{ needs.prepare.outputs.version }}"
payload_root="publish/linux-x64/app-$version"
release_dir="$PWD/release-assets"
stage_dir="$PWD/payload-stage/linux-x64"
if [ ! -d "$payload_root" ]; then
echo "Payload root not found: $payload_root"
exit 1
fi
- name: Upload
rm -rf "$stage_dir"
mkdir -p "$stage_dir" "$release_dir"
rsync -a \
--exclude '.current' \
--exclude '.partial' \
--exclude '.destroy' \
"$payload_root/" "$stage_dir/"
(
cd "$stage_dir"
zip -qr "$release_dir/files-linux-x64.zip" .
)
- name: Upload Release Assets
uses: actions/upload-artifact@v4
with:
name: release-linux
path: "*.deb"
name: release-linux-x64
path: |
release-assets/files-linux-x64.zip
*.deb
if-no-files-found: error
retention-days: 30
build-macos:
needs: prepare
runs-on: macos-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }}
@@ -707,10 +536,14 @@ jobs:
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -725,8 +558,6 @@ jobs:
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \
@@ -737,15 +568,11 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Main App
run: |
@@ -765,7 +592,22 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Package Payload Zip
run: |
release_dir="$PWD/release-assets"
stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}"
payload_root="publish/macos-${{ matrix.arch }}-app"
rm -rf "$stage_dir"
mkdir -p "$stage_dir" "$release_dir"
rsync -a "$payload_root/" "$stage_dir/"
(
cd "$stage_dir"
zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" .
)
- name: Restructure and Package as DMG
continue-on-error: true
run: |
version="${{ needs.prepare.outputs.version }}"
arch="${{ matrix.arch }}"
@@ -774,41 +616,19 @@ jobs:
launcherDir="publish/launcher-macos-$arch"
appSourceDir="publish/macos-$arch-app"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "${app_name}.app/Contents/MacOS"
appDir="app-$version"
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
if [ -d "$appSourceDir" ]; then
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
else
echo "Error: Main app source directory not found: $appSourceDir"
exit 1
fi
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources"
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
echo "App bundle contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: App bundle is empty after copy"
exit 1
fi
{
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
@@ -832,105 +652,96 @@ jobs:
mkdir -p dmg-temp
cp -r "${app_name}.app" dmg-temp/
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
echo "Successfully created: ${package_name}.dmg"
ls -lh "${package_name}.dmg"
else
echo "Error: Failed to create DMG"
exit 1
fi
rm -rf dmg-temp "${app_name}.app"
- name: Upload
- name: Upload Release Assets
if: always()
uses: actions/upload-artifact@v4
with:
name: release-macos-${{ matrix.arch }}
path: "*.dmg"
if-no-files-found: error
path: |
release-assets/files-macos-${{ matrix.arch }}.zip
*.dmg
if-no-files-found: ignore
retention-days: 30
github-release:
needs: [ prepare, build-windows, build-linux, build-macos ]
needs: [prepare, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
path: release-files
pattern: release-*
merge-multiple: true
- name: List artifacts structure
- name: Normalize release files
run: |
echo "Artifact directory structure:"
find artifacts -type f -o -type d | sort
echo ""
echo "Files found:"
find artifacts -type f -exec ls -lh {} \;
echo ""
echo "Full tree:"
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
mkdir -p release-bundle
- name: Flatten artifacts for release
run: |
echo "Organizing artifacts..."
mkdir -p release-files
# Copy installers and packages
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
# Copy delta update files (files.json, files.json.sig, update.zip)
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
# Copy app package for future delta generation (app-{version}-win-{arch}.zip)
find artifacts -type f -name "app-*.zip" -exec cp -v {} release-files/ \;
echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"
echo ""
echo "Total files:"
file_count=$(find release-files -type f | wc -l)
echo "$file_count"
if [ "$file_count" -eq 0 ]; then
echo "Error: No release files found"
mapfile -t downloaded_files < <(find release-files -type f)
if [ "${#downloaded_files[@]}" -eq 0 ]; then
echo "No downloaded release artifacts were found."
exit 1
fi
- name: Create Release
for file in "${downloaded_files[@]}"; do
base_name="$(basename "$file")"
target_path="release-bundle/$base_name"
if [ -e "$target_path" ]; then
echo "Duplicate release asset name detected: $base_name"
echo "Conflicting file: $file"
exit 1
fi
cp "$file" "$target_path"
done
- name: Validate release files
run: |
echo "Release files:"
find release-bundle -maxdepth 1 -type f -exec ls -lh {} \;
if [ ! -f release-bundle/files-windows-x64.zip ] || [ ! -f release-bundle/files-windows-x86.zip ] || [ ! -f release-bundle/files-linux-x64.zip ]; then
echo "Required payload zips are missing."
exit 1
fi
file_count=$(find release-bundle -maxdepth 1 -type f | wc -l)
if [ "$file_count" -eq 0 ]; then
echo "No release files were produced."
exit 1
fi
- name: Create or Update Release
uses: ncipollo/release-action@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }}
commit: ${{ github.sha }}
allowUpdates: true
draft: false
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
artifacts: "release-files/**"
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
artifacts: 'release-bundle/*'
body: |
## Release ${{ needs.prepare.outputs.version }}
### Windows
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
### Installers
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (Windows x64)
- **files.json** - Update manifest listing changed files
- **files.json.sig** - RSA signature of the manifest
- **update.zip** - Archive containing changed files
Existing users: The app will automatically detect and apply the incremental update on next launch.
### Linux
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
### Payload Archives
- `files-windows-x64.zip`
- `files-windows-x86.zip`
- `files-linux-x64.zip`
### macOS
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
- macOS assets are best-effort and will not block the release.
See commits for changes.
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -512,3 +512,5 @@ nul
/*.deb
/*.dmg
/*.AppImage
/velopack-output-local-verify
/velopack-output-local

View File

@@ -0,0 +1,13 @@
# Checklist
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
- [ ] `release.yml` uploads app payload artifacts for PDCC.
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
- [ ] Host can persist PDC payload into launcher incoming directory.
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
- [ ] CI run attached proving all release matrix jobs pass.
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
- [ ] Rollback verification report attached.

View File

@@ -0,0 +1,44 @@
# 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)
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
- Promote PDC-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).
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
Expected S3 layout:
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
- `lanmountain/update/installers/<platform>/<arch>/*`
## Acceptance
- `release.yml` includes PDCC publish steps and no Velopack steps.
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
- Launcher can apply both:
- legacy signed `files.json + update.zip`
- PDC FileMap object-repo payload.
- Rollback semantics remain unchanged.
## Deprecated Notes
- The following interim outputs are compatibility-only (not the long-term primary path):
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`

View File

@@ -0,0 +1,15 @@
# Tasks
- [x] Remove VeloPack packaging from release workflow.
- [x] Keep signed FileMap path as interim compatibility fallback.
- [x] Remove launcher/runtime Velopack branching.
- [ ] Add `phainon.yml` for PDCC publish configuration.
- [ ] Add PDCC installation + publish steps in `release.yml`.
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
- [ ] Add PDC payload model into host update check result.
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.

View File

@@ -0,0 +1,5 @@
# Checklist (Deprecated)
- [x] Spec marked as deprecated.
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
- [x] No release workflow dependency remains on VeloPack.

View File

@@ -0,0 +1,15 @@
# VeloPack Update Integration (Deprecated)
## Status
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
## Deprecation Reason
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
- The project has switched back to signed FileMap incremental assets as the primary update path.
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
## Migration Note
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.

View File

@@ -0,0 +1,6 @@
# Tasks (Deprecated)
- [x] Mark VeloPack integration spec as deprecated.
- [x] Remove VeloPack runtime branches from launcher/host update path.
- [x] Remove VeloPack release workflow packaging steps.
- [ ] Keep archive for historical context only (no new implementation tasks here).

View File

@@ -214,14 +214,12 @@ public partial class App : Application
var deploymentLocator = new DeploymentLocator(appRoot);
// TODO: 从配置读取 GitHub 仓库信息
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
updateCheckService,
new PluginInstallerService());
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(PlondsUpdateMetadata))]
[JsonSerializable(typeof(PlondsFileMap))]
[JsonSerializable(typeof(PlondsComponentEntry))]
[JsonSerializable(typeof(PlondsFileEntry))]
[JsonSerializable(typeof(PlondsHashDescriptor))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -1,8 +1,11 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
-----END RSA PUBLIC KEY-----
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
FQiGowgqx0l5AgMBAAE=
-----END RSA PUBLIC KEY-----

View File

@@ -56,7 +56,11 @@
<!-- 允许 IL 警告 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- FluentAvaloniaUI 需要启用反射序列化AOT 兼容模式) -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -53,3 +53,92 @@ internal sealed class UpdateApplyResult
public string? RolledBackTo { get; init; }
}
internal sealed class PlondsUpdateMetadata
{
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 PlondsFileMap
{
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<PlondsComponentEntry> Components { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
public string? Version { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsFileEntry
{
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 PlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsHashDescriptor
{
public string? Algorithm { get; set; }
public string? Value { get; set; }
public byte[]? Bytes { get; set; }
}

View File

@@ -91,11 +91,7 @@ internal static class Commands
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => 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),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
_ => new LauncherResult
{
Success = false,
@@ -106,6 +102,15 @@ internal static class Commands
};
}
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand(
CommandContext context,
PluginInstallerService pluginInstaller,
@@ -149,10 +154,7 @@ internal static class Commands
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
});
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}

View File

@@ -322,7 +322,7 @@ internal sealed class DeploymentLocator
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json);
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{
if (Directory.Exists(snapshot.SourceDirectory))
@@ -445,7 +445,7 @@ internal sealed class DeploymentLocator
try
{
var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize<AppVersionInfo>(json);
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
if (info is not null)
{
return info;

View File

@@ -159,7 +159,7 @@ namespace LanMountainDesktop.Launcher.Services;
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json);
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
@@ -617,13 +617,13 @@ namespace LanMountainDesktop.Launcher.Services;
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
/// <summary>
/// 发现配置文件
/// </summary>
private class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}
}
/// <summary>
/// 发现配置文件
/// </summary>
internal class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}

View File

@@ -143,7 +143,7 @@ public class LauncherIpcServer : IDisposable
// 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
if (message is not null)
{
_onProgress(message);

View File

@@ -9,11 +9,19 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator
{
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
private readonly UpdateEngineService _updateEngine;
private readonly UpdateCheckService _updateCheckService;
private readonly PluginInstallerService _pluginInstallerService;
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
@@ -22,14 +30,12 @@ internal sealed class LauncherFlowCoordinator
DeploymentLocator deploymentLocator,
OobeStateService oobeStateService,
UpdateEngineService updateEngine,
UpdateCheckService updateCheckService,
PluginInstallerService pluginInstallerService)
{
_context = context;
_deploymentLocator = deploymentLocator;
_oobeStateService = oobeStateService;
_updateEngine = updateEngine;
_updateCheckService = updateCheckService;
_pluginInstallerService = pluginInstallerService;
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
}
@@ -167,21 +173,31 @@ internal sealed class LauncherFlowCoordinator
var processExitTask = hostProcess.WaitForExitAsync();
// 等待主程序就绪或进程退出(取先发生者)
// 延长超时到 120 秒,给主程序足够的加载时间
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
var readyOrTimeoutOrExit = Task.WhenAny(
hostReadyTcs.Task,
processExitTask,
Task.Delay(TimeSpan.FromSeconds(120)));
Task.Delay(TimeSpan.FromSeconds(30)));
var completedTask = await readyOrTimeoutOrExit;
// 检查是否是进程先退出(异常情况)
// Host process exited before reporting Ready.
if (completedTask == processExitTask)
{
var exitCode = hostProcess.ExitCode;
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited unexpectedly with code: {exitCode}");
// 关闭 Splash 窗口
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
exitCode,
hostReadyTcs,
splashWindow,
loadingDetailsWindow).ConfigureAwait(false);
if (recoveryResult is not null)
{
return recoveryResult;
}
// Close Splash window for unrecoverable early exits.
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
@@ -196,7 +212,7 @@ internal sealed class LauncherFlowCoordinator
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
}
});
return new LauncherResult
{
Success = false,
@@ -279,6 +295,133 @@ internal sealed class LauncherFlowCoordinator
}
}
private async Task<LauncherResult?> TryRecoverFromEarlyHostExitAsync(
int exitCode,
TaskCompletionSource hostReadyTcs,
SplashWindow splashWindow,
LoadingDetailsWindow? loadingDetailsWindow)
{
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
{
Console.WriteLine("[LauncherFlowCoordinator] Host redirected activation to an existing primary instance.");
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activated_existing_instance",
Message = "Detected existing running instance and activation was acknowledged."
};
}
if (exitCode is not HostExitCodes.SecondaryActivationFailed and not HostExitCodes.RestartLockNotAcquired)
{
return null;
}
Console.Error.WriteLine(
$"[LauncherFlowCoordinator] Activation handshake failed with exit code {exitCode}. Retrying explicit activation once...");
var (retryLaunchResult, retryProcess) = await LaunchHostWithIpcAsync(splashWindow).ConfigureAwait(false);
if (!retryLaunchResult.Success)
{
return retryLaunchResult;
}
if (retryProcess is null)
{
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_start_failed",
Message = "Explicit activation retry failed because no host process was created."
};
}
Console.WriteLine($"[LauncherFlowCoordinator] Explicit activation retry started. RetryPid={retryProcess.Id}.");
var retryExitTask = retryProcess.WaitForExitAsync();
var retryCompleted = await Task.WhenAny(
hostReadyTcs.Task,
retryExitTask,
Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
if (retryCompleted == hostReadyTcs.Task)
{
Console.WriteLine("[LauncherFlowCoordinator] Host reported Ready after explicit activation retry.");
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_retry_ready",
Message = "Explicit activation retry succeeded and host reported Ready."
};
}
if (retryCompleted == retryExitTask)
{
var retryExitCode = retryProcess.ExitCode;
if (retryExitCode == HostExitCodes.SecondaryActivationSucceeded)
{
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_retry_redirected",
Message = "Explicit activation retry redirected to the existing primary instance."
};
}
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_failed",
Message = $"Explicit activation retry failed. ExitCode={retryExitCode}. 请结束残留后台进程后重试。"
};
}
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_timeout",
Message = "Explicit activation retry timed out before host became ready. 请结束残留后台进程后重试。"
};
}
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close splash window: {ex.Message}");
}
try
{
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
{
loadingDetailsWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close loading details window: {ex.Message}");
}
});
}
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
{
// 优先使用自定义路径(调试模式选择的路径)
@@ -315,36 +458,62 @@ internal sealed class LauncherFlowCoordinator
EnsureExecutable(hostPath);
}
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
var versionInfo = _deploymentLocator.GetVersionInfo();
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0)
// 避免子进程窗口创建成功但不可见的问题。
var arguments = new System.Text.StringBuilder();
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid
foreach (var arg in _context.RawArgs)
{
if (arg == _context.Command || arg == _context.SubCommand)
continue;
if (arg.StartsWith("--"))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0) key = key[..equalsIndex];
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
continue;
}
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append(QuoteArgument(arg));
}
// 通过命令行参数传递 IPC 连接信息UseShellExecute=true 时不支持 EnvironmentVariables
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
UseShellExecute = true,
WorkingDirectory = hostWorkingDir,
Arguments = arguments.ToString()
};
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项
foreach (var arg in _context.RawArgs)
{
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
{
continue;
}
processStartInfo.ArgumentList.Add(arg);
}
// 传递环境变量供 IPC 使用
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot();
// 传递版本信息
var versionInfo = _deploymentLocator.GetVersionInfo();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
var hostProcess = Process.Start(processStartInfo);
Console.WriteLine(
$"[LauncherFlowCoordinator] Host launch requested. Path='{hostPath}'; WorkingDir='{hostWorkingDir}'; " +
$"Pid={(hostProcess is null ? -1 : hostProcess.Id)}; Args='{processStartInfo.Arguments}'.");
return (new LauncherResult
{
Success = true,
@@ -483,6 +652,36 @@ internal sealed class LauncherFlowCoordinator
return result;
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())

View File

@@ -73,7 +73,7 @@ internal sealed class PluginInstallerService
using var stream = entries[0].Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
if (manifest == null)
{
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");

View File

@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
@@ -63,10 +63,7 @@ internal sealed class PluginUpgradeQueueService
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
{
WriteIndented = true
}));
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
}
return new LauncherResult
@@ -79,19 +76,19 @@ internal sealed class PluginUpgradeQueueService
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
};
}
}
private sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
internal sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}

View File

@@ -15,7 +15,6 @@ internal sealed class UpdateCheckService
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public UpdateCheckService(string repoOwner, string repoName)
{
@@ -24,12 +23,6 @@ internal sealed class UpdateCheckService
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
}
/// <summary>
@@ -97,7 +90,7 @@ internal sealed class UpdateCheckService
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
return releases?.Select(r => new ReleaseInfo
{
@@ -131,38 +124,38 @@ internal sealed class UpdateCheckService
var cleaned = ParseVersionString(versionString);
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
}
// GitHub API 响应模型
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}
}
// GitHub API 响应模型
internal sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
internal sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
<Window xmlns="https://github.com/avaloniaui"
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
Title="阑山桌面 - 加载详情"
Title="LanMountain Desktop - Loading Details"
Width="600"
Height="500"
WindowStartupLocation="CenterScreen"
@@ -17,18 +18,17 @@
Icon="/Assets/logo.ico">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 标题栏 -->
<Border Grid.Row="0"
<Border Grid.Row="0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="20,16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="正在启动阑山桌面"
<TextBlock Text="Starting LanMountain Desktop"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock x:Name="SubtitleText"
Text="初始化系统组件..."
Text="Initializing..."
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel>
@@ -46,7 +46,6 @@
</Grid>
</Border>
<!-- 主要内容区域 -->
<Grid Grid.Row="1" Margin="16,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@@ -54,7 +53,6 @@
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 整体进度条 -->
<ProgressBar x:Name="OverallProgressBar"
Grid.Row="0"
Height="8"
@@ -64,14 +62,12 @@
CornerRadius="4"
Margin="0,0,0,16"/>
<!-- 当前活动项 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="8"
Padding="16,12"
Margin="0,0,0,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<!-- 图标 -->
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
Width="40"
Height="40"
@@ -88,23 +84,20 @@
VerticalAlignment="Center"/>
</Border>
<!-- 名称 -->
<TextBlock x:Name="CurrentItemName"
Grid.Row="0" Grid.Column="1"
Text="正在初始化..."
Text="Initializing..."
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<!-- 描述 -->
<TextBlock x:Name="CurrentItemDescription"
Grid.Row="1" Grid.Column="1"
Text="准备加载系统组件"
Text="Preparing components"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0"/>
<!-- 进度 -->
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
<ProgressBar x:Name="CurrentItemProgress"
Height="4"
@@ -116,15 +109,13 @@
</Grid>
</Border>
<!-- 加载项列表 -->
<Border Grid.Row="2"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8">
<Grid RowDefinitions="Auto,*">
<!-- 列表标题 -->
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0"
Text="加载项"
Text="Loading Items"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
@@ -135,22 +126,20 @@
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,0,4,0"/>
<TextBlock Grid.Column="2"
Text="已完成"
Text="Done"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
</Grid>
<!-- 列表内容 -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Margin="8,0,8,8">
<ItemsControl x:Name="LoadingItemsList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
<DataTemplate DataType="views:LoadingItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
Margin="4,3"
Opacity="{Binding Opacity}">
<!-- 状态图标 -->
<TextBlock Grid.Column="0"
Text="{Binding StatusIcon}"
FontSize="14"
@@ -159,7 +148,6 @@
Margin="0,0,8,0"
VerticalAlignment="Center"/>
<!-- 名称 -->
<TextBlock Grid.Column="1"
Text="{Binding Name}"
FontSize="13"
@@ -167,7 +155,6 @@
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<!-- 进度 -->
<TextBlock Grid.Column="2"
Text="{Binding ProgressText}"
FontSize="12"
@@ -175,7 +162,6 @@
Margin="8,0"
VerticalAlignment="Center"/>
<!-- 类型标签 -->
<Border Grid.Column="3"
Background="{Binding TypeBackground}"
CornerRadius="4"
@@ -194,7 +180,6 @@
</Border>
</Grid>
<!-- 错误信息区域 -->
<Border x:Name="ErrorPanel"
Grid.Row="2"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
@@ -214,14 +199,13 @@
VerticalAlignment="Center"/>
<TextBlock x:Name="ErrorText"
Grid.Column="1"
Text="加载过程中出现错误"
Text="An error occurred while loading."
FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"/>
</Grid>
</Border>
<!-- 底部按钮 -->
<Border Grid.Row="3"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16,12">
@@ -234,12 +218,12 @@
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button x:Name="DetailsButton"
Content="查看详情"
Content="Details"
Width="90"
Height="32"
FontSize="13"/>
<Button x:Name="CancelButton"
Content="取消"
Content="Cancel"
Width="90"
Height="32"
FontSize="13"/>

View File

@@ -0,0 +1,18 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// Standardized host process exit codes consumed by the launcher.
/// </summary>
public static class HostExitCodes
{
public const int Success = 0;
// Secondary instance activated the existing primary instance successfully.
public const int SecondaryActivationSucceeded = 12;
// Secondary instance failed to activate the existing primary instance.
public const int SecondaryActivationFailed = 13;
// Restart relaunch couldn't acquire the single-instance lock in time.
public const int RestartLockNotAcquired = 14;
}

View File

@@ -0,0 +1,102 @@
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class SingleInstanceServiceTests
{
[Fact]
public async Task TryNotifyPrimaryInstance_ReturnsTrue_WhenPrimaryAcknowledges()
{
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
using var primary = CreateService(mutexName, pipeName);
using var secondary = CreateSecondaryService(mutexName, pipeName);
Assert.True(primary.IsPrimaryInstance);
MarkAsSecondaryForTest(secondary);
var activated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
primary.StartActivationListener(() => activated.TrySetResult());
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
Assert.True(acknowledged);
Assert.Null(failureReason);
var completed = await Task.WhenAny(activated.Task, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(activated.Task, completed);
}
[Fact]
public void TryNotifyPrimaryInstance_ReturnsFalse_WhenListenerIsNotRunning()
{
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
using var primary = CreateService(mutexName, pipeName);
using var secondary = CreateSecondaryService(mutexName, pipeName);
Assert.True(primary.IsPrimaryInstance);
MarkAsSecondaryForTest(secondary);
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromMilliseconds(300), out var failureReason);
Assert.False(acknowledged);
Assert.False(string.IsNullOrWhiteSpace(failureReason));
}
private static SingleInstanceService CreateService(string mutexName, string pipeName)
{
var ctor = typeof(SingleInstanceService).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
[typeof(string), typeof(string)],
modifiers: null);
Assert.NotNull(ctor);
return (SingleInstanceService)ctor!.Invoke([mutexName, pipeName]);
}
private static SingleInstanceService CreateSecondaryService(string mutexName, string pipeName)
{
SingleInstanceService? created = null;
Exception? creationError = null;
var thread = new Thread(() =>
{
try
{
created = CreateService(mutexName, pipeName);
}
catch (Exception ex)
{
creationError = ex;
}
});
thread.IsBackground = true;
thread.Start();
thread.Join();
if (creationError is not null)
{
throw new InvalidOperationException("Failed to create secondary SingleInstanceService.", creationError);
}
Assert.NotNull(created);
return created!;
}
private static void MarkAsSecondaryForTest(SingleInstanceService service)
{
var ownsMutexField = typeof(SingleInstanceService).GetField(
"_ownsMutex",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(ownsMutexField);
ownsMutexField!.SetValue(service, false);
Assert.False(service.IsPrimaryInstance);
}
}

View File

@@ -77,6 +77,8 @@ public partial class App : Application
private LauncherIpcClient? _launcherIpcClient;
private LoadingStateManager? _loadingStateManager;
private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased;
private int _forcedExitScheduled;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
@@ -142,7 +144,7 @@ public partial class App : Application
EnsureNotificationService();
}
public override async void OnFrameworkInitializationCompleted()
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode)
{
@@ -152,12 +154,8 @@ public partial class App : Application
AppLogger.Info("App", "Framework initialization completed.");
// 初始化 Launcher IPC 客户端(如果从 Launcher 启动)
await InitializeLauncherIpcAsync();
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
@@ -166,6 +164,10 @@ public partial class App : Application
}
base.OnFrameworkInitializationCompleted();
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
// 使用 fire-and-forget 模式,不阻塞主流程
_ = InitializeLauncherIpcAsync();
}
private async Task InitializeLauncherIpcAsync()
@@ -189,9 +191,10 @@ public partial class App : Application
// 注册系统初始化加载项
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
_loadingStateManager.StartItem("system.init", "正在连接启动器...");
_loadingStateManager.StartItem("system.init", "连接启动器");
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
}
}
catch (Exception ex)
@@ -227,7 +230,7 @@ public partial class App : Application
}
/// <summary>
/// 同步向 Launcher 报告启动进度,确保关键消息可靠送达
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
/// 用于 Ready 等关键状态报告
/// </summary>
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
@@ -237,27 +240,27 @@ public partial class App : Application
try
{
// 使用同步等待确保消息发送完成
var task = _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
_ = Task.Run(async () =>
{
Stage = stage,
ProgressPercent = percent,
Message = message
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = stage,
ProgressPercent = percent,
Message = message
});
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
}
});
// 等待最多 5 秒,确保消息发送成功
if (!task.Wait(TimeSpan.FromSeconds(5)))
{
AppLogger.Warn("LauncherIpc", "Report progress timeout after 5 seconds");
}
else
{
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
}
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress synchronously: {ex.Message}");
AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
}
}
@@ -289,16 +292,20 @@ public partial class App : Application
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
},
() =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
},
OnDesktopLifetimeExit,
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
StartWeatherLocationRefreshIfNeeded);
_desktopShellHost.Initialize(this);
}
private void OnDesktopLifetimeExit()
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
ReleaseSingleInstanceAfterExit("DesktopLifetimeExit");
ScheduleForcedProcessTermination("DesktopLifetimeExit");
}
private void OnTrayExitClick(object? sender, EventArgs e)
{
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
@@ -658,70 +665,102 @@ public partial class App : Application
private void ActivateMainWindow()
{
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
try
{
var restored = Dispatcher.UIThread.CheckAccess()
? RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance")
: Dispatcher.UIThread.InvokeAsync(
() => RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance"),
DispatcherPriority.Send).GetAwaiter().GetResult();
if (!restored)
{
throw new InvalidOperationException("Main window restore failed in activation callback.");
}
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
throw;
}
}
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
{
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
try
{
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
if (showSingleInstanceNotice)
{
mainWindow.ShowSingleInstanceNotice();
}
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
}
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
}, DispatcherPriority.Send);
}
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
return false;
}
try
{
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
if (showSingleInstanceNotice)
{
mainWindow.ShowSingleInstanceNotice();
}
return true;
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
return false;
}
}
private void EnsureTransparentOverlayWindow()
{
@@ -884,6 +923,57 @@ public partial class App : Application
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
}
private void ReleaseSingleInstanceAfterExit(string source)
{
if (_singleInstanceReleased)
{
return;
}
_singleInstanceReleased = true;
var singleInstance = CurrentSingleInstanceService;
CurrentSingleInstanceService = null;
if (singleInstance is null)
{
AppLogger.Info("SingleInstance", $"No single-instance handle to release. Source='{source}'.");
return;
}
try
{
singleInstance.Dispose();
AppLogger.Info("SingleInstance", $"Released single-instance handle. Source='{source}'.");
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", $"Failed to release single-instance handle. Source='{source}'.", ex);
}
}
private void ScheduleForcedProcessTermination(string source)
{
if (Interlocked.Exchange(ref _forcedExitScheduled, 1) != 0)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(8)).ConfigureAwait(false);
AppLogger.Warn(
"DesktopShell",
$"Process did not terminate after desktop exit cleanup. Forcing process exit. Source='{source}'; ShutdownIntent='{_shutdownIntent}'.");
Environment.Exit(0);
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Forced process termination scheduler failed. Source='{source}'.", ex);
}
});
}
private void PerformExitCleanup()
{
if (_exitCleanupCompleted)
@@ -934,6 +1024,22 @@ public partial class App : Application
disposableRegistry.Dispose();
}
if (_transparentOverlayWindow is not null)
{
try
{
_transparentOverlayWindow.Close();
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
}
finally
{
_transparentOverlayWindow = null;
}
}
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
@@ -979,6 +1085,33 @@ public partial class App : Application
// 延迟报告 Ready 直到窗口实际打开并可见
// 使用 Opened 事件确保所有资源已加载完毕
mainWindow.Opened += OnMainWindowOpened;
// 手动显示窗口,因为在 ShutdownMode.OnExplicitShutdown 模式下框架不会自动调用 Show
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(10));
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
{
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Ready,
ProgressPercent = 100,
Message = "就绪"
});
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
}
catch { }
}
});
return mainWindow;
}
@@ -1132,11 +1265,9 @@ public partial class App : Application
"DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
// 检查三指滑动功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (appSnapshot.EnableThreeFingerSwipe)
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
{
// 显示透明覆盖层窗口
EnsureTransparentOverlayWindow();
_transparentOverlayWindow?.Show();
}

View File

@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
public string UpdateMode { get; set; } = "download_then_confirm";
public string UpdateDownloadSource { get; set; } = "github";
public string UpdateDownloadSource { get; set; } = "stcn";
public int UpdateDownloadThreads { get; set; } = 4;

View File

@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop;
@@ -32,11 +33,26 @@ public sealed class Program
AppLogger.Warn(
"Startup",
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
return;
}
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
if (activationAcknowledged)
{
AppLogger.Info(
"Startup",
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
}
else
{
AppLogger.Warn(
"Startup",
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
}
return;
}

View File

@@ -100,12 +100,15 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
var args = new System.Text.StringBuilder();
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -122,13 +125,16 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = dotnetHostPath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串
var args = new System.Text.StringBuilder();
args.Append(QuoteArgument(entryAssemblyPath));
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -145,11 +151,61 @@ public static class AppRestartService
}
}
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
if (builder.Length > 0) builder.Append(' ');
builder.Append(QuoteArgument(commandLineArgs[i]));
}
}
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
{
if (builder.Length > 0) builder.Append(' ');
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;

View File

@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
{
// 已存在,可能只更新位置或尺寸
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
if (existingWindow.IsVisible == false)
{
existingWindow.Show();

View File

@@ -34,7 +34,20 @@ public sealed record UpdateCheckResult(
GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage,
bool ForceMode = false);
bool ForceMode = false,
PlondsUpdatePayload? PlondsPayload = null);
public sealed record PlondsUpdatePayload(
string DistributionId,
string ChannelId,
string SubChannel,
string? FileMapJson,
string? FileMapSignature,
string? FileMapJsonUrl,
string? FileMapSignatureUrl,
string? UpdateArchiveUrl = null,
string? UpdateArchiveSha256 = null,
long? UpdateArchiveSizeBytes = null);
public sealed record UpdateDownloadResult(
bool Success,
@@ -149,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var preferredAsset = isUpdateAvailable
? SelectPreferredInstallerAsset(release.Assets)
: null;
var plondsPayload = isUpdateAvailable
? TryResolvePlondsPayload(release)
: null;
return new UpdateCheckResult(
Success: true,
@@ -157,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null);
ErrorMessage: null,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -222,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
: release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
var plondsPayload = TryResolvePlondsPayload(release);
return new UpdateCheckResult(
Success: true,
@@ -231,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: true);
ForceMode: true,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -642,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
if (assets is null || assets.Count == 0)
{
return null;
}
@@ -654,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
_ => "x64"
};
var ranked = assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.ToList();
if (OperatingSystem.IsWindows())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
if (OperatingSystem.IsLinux())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreLinuxInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
if (OperatingSystem.IsMacOS())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreMacInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return null;
}
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
{
if (release.Assets is null || release.Assets.Count == 0)
{
return null;
}
var platformSuffix = GetPlatformAssetSuffix();
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
{
return null;
}
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
var channelId = release.IsPrerelease
? UpdateSettingsValues.ChannelPreview
: UpdateSettingsValues.ChannelStable;
return new PlondsUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: platformSuffix,
FileMapJson: null,
FileMapSignature: null,
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
UpdateArchiveSha256: archiveAsset.Sha256,
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
}
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
{
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
}
private static string GetPlatformAssetSuffix()
{
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}-{arch}";
}
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
@@ -709,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return score;
}
private static int ScoreLinuxInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".deb", StringComparison.OrdinalIgnoreCase))
{
score += 220;
}
else if (assetName.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase))
{
score += 180;
}
else if (assetName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
{
score += 160;
}
else
{
return 0;
}
if (assetName.Contains("linux", StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase) ||
(architectureToken == "x64" && assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase)))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static int ScoreMacInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase))
{
score += 220;
}
else if (assetName.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
{
score += 180;
}
else
{
return 0;
}
if (assetName.Contains("mac", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("osx", StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;

View File

@@ -17,6 +17,11 @@ public class LauncherIpcClient : IDisposable
private bool _isConnected;
private readonly object _writeLock = new();
/// <summary>
/// 是否已连接到 Launcher
/// </summary>
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// </summary>
@@ -92,11 +97,28 @@ public class LauncherIpcClient : IDisposable
/// <summary>
/// 检查是否从 Launcher 启动
/// 优先检查环境变量回退到命令行参数UseShellExecute=true 时环境变量仍可继承,
/// 命令行参数作为备选确保兼容性)
/// </summary>
public static bool IsLaunchedByLauncher()
{
return !string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
// 优先检查环境变量
if (!string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
{
return true;
}
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>
foreach (var arg in Environment.GetCommandLineArgs())
{
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public void Dispose()

View File

@@ -1,4 +1,5 @@
using System.Timers;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;

View File

@@ -0,0 +1,80 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// Release-backed PLONDS checker.
/// It only succeeds when the latest GitHub Release already exposes platform PLONDS assets.
/// If those assets are not ready yet, callers can fall back to the normal GitHub installer flow.
/// </summary>
public sealed class PlondsReleaseUpdateService : IDisposable
{
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var releaseResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (!releaseResult.Success)
{
return releaseResult;
}
if (!isForce && !releaseResult.IsUpdateAvailable)
{
return releaseResult with { ForceMode = false };
}
if (releaseResult.PlondsPayload is not null)
{
return releaseResult with { ForceMode = isForce };
}
var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText)
? "-"
: releaseResult.LatestVersionText;
var message = releaseResult.Release is null
? "GitHub Release data is unavailable for PLONDS."
: $"Release {latestVersion} does not expose platform PLONDS assets yet.";
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: releaseResult.IsUpdateAvailable,
CurrentVersionText: releaseResult.CurrentVersionText,
LatestVersionText: latestVersion,
Release: releaseResult.Release,
PreferredAsset: releaseResult.PreferredAsset,
ErrorMessage: message,
ForceMode: isForce,
PlondsPayload: null);
}
}

View File

@@ -356,6 +356,7 @@ public interface IUpdateSettingsService
void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,

View File

@@ -751,7 +751,8 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
{
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
@@ -838,7 +839,19 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
Version currentVersion,
bool includePrerelease,
bool isForce = false,
CancellationToken cancellationToken = default)
{
var result = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return result.Success ? result.PlondsPayload : null;
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
@@ -849,7 +862,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.DownloadAssetAsync(
return _githubReleaseUpdateService.DownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -866,7 +879,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.RedownloadAssetAsync(
return _githubReleaseUpdateService.RedownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -877,7 +890,55 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_releaseUpdateService.Dispose();
_githubReleaseUpdateService.Dispose();
_plondsReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (plondsResult.Success)
{
return plondsResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
AppLogger.Info(
"UpdateSettings",
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
}
else
{
AppLogger.Warn(
"UpdateSettings",
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
}
return githubFallbackResult;
}
return isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
}
@@ -1229,14 +1290,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppVersionText()
{
// 优先从环境变量读取(Launcher 传递)
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
if (!string.IsNullOrWhiteSpace(envVersion))
{
return envVersion;
}
// 回退:从程序集读取
// Fallback: read from application assembly.
var assembly = typeof(App).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
@@ -1276,14 +1337,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppCodenameText()
{
// 优先从环境变量读取(Launcher 传递)
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
if (!string.IsNullOrWhiteSpace(envCodename))
{
return envCodename;
}
// 回退:使用默认开发代号
// Fallback: use default codename.
return DefaultCodename;
}

View File

@@ -9,6 +9,10 @@ namespace LanMountainDesktop.Services;
public sealed class SingleInstanceService : IDisposable
{
private const byte ActivationRequestCode = 0x41; // 'A'
private const byte ActivationAckCode = 0x4B; // 'K'
private const byte ActivationNackCode = 0x4E; // 'N'
private readonly Mutex _mutex;
private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new();
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
return;
}
AppLogger.Info(
"SingleInstance",
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
{
return TryNotifyPrimaryInstance(timeout, out _);
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
{
if (_ownsMutex || _disposed)
{
failureReason = _ownsMutex
? "current_instance_is_primary"
: "single_instance_service_disposed";
return false;
}
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
using var client = new NamedPipeClientStream(
serverName: ".",
pipeName: _pipeName,
direction: PipeDirection.Out,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
client.WriteByte(1);
client.WriteByte(ActivationRequestCode);
client.Flush();
var ack = client.ReadByte();
var acknowledged = ack == ActivationAckCode;
if (!acknowledged)
{
failureReason = ack switch
{
ActivationNackCode => "primary_rejected_activation",
-1 => "ack_not_received",
_ => $"unexpected_ack_code_{ack}"
};
AppLogger.Warn(
"SingleInstance",
$"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return false;
}
failureReason = null;
AppLogger.Info(
"SingleInstance",
$"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return true;
}
catch (Exception ex)
{
failureReason = "primary_activation_handshake_exception";
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
return false;
}
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
{
using var server = new NamedPipeServerStream(
_pipeName,
PipeDirection.In,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
onActivationRequested();
var buffer = new byte[1];
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
var ackCode = ActivationAckCode;
if (!isActivationRequest)
{
ackCode = ActivationNackCode;
AppLogger.Warn(
"SingleInstance",
$"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
}
else
{
try
{
onActivationRequested();
}
catch (Exception ex)
{
ackCode = ActivationNackCode;
AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
}
}
var ackBuffer = new[] { ackCode };
await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
await server.FlushAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{

View File

@@ -11,6 +11,12 @@ public static class UpdateSettingsValues
public const string ModeDownloadThenConfirm = "download_then_confirm";
public const string ModeSilentOnExit = "silent_on_exit";
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePlonds = "stcn";
public const string DownloadSourcePdc = DownloadSourcePlonds;
public const string DownloadSourceStcn = DownloadSourcePlonds;
public const string LegacyDownloadSourcePlonds = "pdc";
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
@@ -51,9 +57,28 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
? DownloadSourceGhProxy
: DownloadSourceGitHub;
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceStcn;
}
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePlonds;
}
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGhProxy;
}
if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGitHub;
}
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
return DownloadSourceStcn;
}
public static int NormalizeDownloadThreads(int value)

File diff suppressed because it is too large Load Diff

View File

@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty]
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
[ObservableProperty]
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _previewChannelText = string.Empty;
[ObservableProperty]
private string _pdcSourceText = string.Empty;
[ObservableProperty]
private string _gitHubSourceText = string.Empty;
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsPreviewChannelSelected =>
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
public bool IsPdcSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
public bool IsGitHubSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
}
[RelayCommand]
private void SelectPdcSource()
{
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
}
[RelayCommand]
private void SelectGitHubSource()
{
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = isForce
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
: L("settings.update.status_checking", "Checking GitHub releases...");
? L("settings.update.status_force_checking", "Force checking update source...")
: L("settings.update.status_checking", "Checking update source...");
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
_lastCheckResult = result.Success ? result : null;
@@ -1953,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
return;
}
if (result.PreferredAsset is null)
if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{
UpdateStatus = isForce
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
@@ -2038,7 +2050,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
private async Task RedownloadUpdateAsync()
{
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
if (_lastCheckResult is null ||
!_lastCheckResult.Success ||
!_lastCheckResult.IsUpdateAvailable ||
(_lastCheckResult.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(_lastCheckResult)))
{
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
return;
@@ -2100,7 +2115,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
@@ -2112,6 +2127,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
StableChannelText = L("settings.update.channel_stable", "Stable");
PreviewChannelText = L("settings.update.channel_preview", "Preview");
PdcSourceText = L("settings.update.source_pdc", "PDC");
GitHubSourceText = L("settings.update.source_github", "GitHub");
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
ManualModeText = L("settings.update.mode_manual", "Manual Update");
@@ -2220,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateDownloadResult downloadResult;
// Prefer delta update if available (smaller download, faster)
if (result.Release is not null && UpdateWorkflowService.IsDeltaUpdateAvailable(result.Release))
if (UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
if (!downloadResult.Success)
if (!downloadResult.Success && result.PlondsPayload is null)
{
// Delta download failed, fall back to full installer
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");
@@ -2309,6 +2325,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
{
UpdateSettingsValues.DownloadSourcePdc => L(
"settings.update.source_pdc_desc",
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
UpdateSettingsValues.DownloadSourceGhProxy => L(
"settings.update.source_ghproxy_desc",
"Use the gh-proxy mirror when downloading GitHub release assets."),
@@ -2360,6 +2379,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
return
[
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
];

View File

@@ -25,6 +25,23 @@ public partial class DesktopWidgetWindow : Window
ComponentContainer.Child = componentContent;
}
public void UpdateComponentLayout(double width, double height)
{
ComponentContainer.Width = width;
ComponentContainer.Height = height;
if (ComponentContainer.Child is Control child)
{
child.Width = width;
child.Height = height;
}
if (OperatingSystem.IsWindows() && IsVisible)
{
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
}
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);

View File

@@ -23,6 +23,8 @@ namespace LanMountainDesktop.Views;
public partial class TransparentOverlayWindow : Window
{
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
// 滑动状态
private bool _isSwipeActive;
@@ -77,6 +79,11 @@ public partial class TransparentOverlayWindow : Window
_weatherDataService = facade.Weather.GetWeatherInfoService();
_timeZoneService = facade.Region.GetTimeZoneService();
_settingsFacade = facade;
if (OperatingSystem.IsWindows())
{
_bottomMostService.SetupBottomMost(this);
}
}
private readonly ISettingsFacadeService _settingsFacade;
@@ -84,6 +91,7 @@ public partial class TransparentOverlayWindow : Window
public void SaveLayoutAndHide()
{
SaveLayout();
_regionPassthroughService.ClearInteractiveRegions(this);
Hide();
// Remove all components so that next time we open it builds fresh from snapshot
@@ -131,6 +139,11 @@ public partial class TransparentOverlayWindow : Window
RenderAllComponents();
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
if (OperatingSystem.IsWindows())
{
_bottomMostService.SendToBottom(this);
}
}
/// <summary>
@@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window
/// </summary>
private void UpdateInteractiveRegions()
{
// 编辑模式下不再需要底层穿透功能计算,这里留空或移除
_interactiveRegions.Clear();
foreach (var host in _componentHosts.Values)
{
var left = Canvas.GetLeft(host);
var top = Canvas.GetTop(host);
var width = host.Width > 0 ? host.Width : host.Bounds.Width;
var height = host.Height > 0 ? host.Height : host.Bounds.Height;
if (width <= 0 || height <= 0)
{
continue;
}
// 稍微向外扩一圈,确保拖拽和右下角缩放手柄也能命中。
_interactiveRegions.Add(new Rect(left - 12, top - 12, width + 24, height + 24));
}
_regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions);
}
/// <summary>

View File

@@ -5,13 +5,18 @@
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 明确指定不需要管理员权限,以调用者权限运行 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<!-- Windows 10/11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>

View File

@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<VersionPrefix>0.1.0</VersionPrefix>
<PackageVersion>0.1.0</PackageVersion>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<FileVersion>0.1.0.0</FileVersion>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,93 @@
# PLONDS 骨架
Penguin Logistics Online Network Distribution System企鹅物流在线网络分发系统简称 PLONDS是 LanMountainDesktop 的独立更新分发骨架。
本目录有意与主应用和启动器隔离,仅包含新的分发协议、一个轻量级的只读 API以及示例 S3 风格的元数据文件。
## 目录结构
```text
PenguinLogisticsOnlineNetworkDistributionSystem/
README.md
src/
Plonds.Shared/
Plonds.Api/
sample-data/
meta/
channels/
stable/
windows-x64/
windows-x86/
linux-x64/
distributions/
```
## 项目说明
- `Plonds.Shared` 提供协议常量和数据模型。
- `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。
- `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。
- `Plonds.Api` 是一个轻量级只读 API从类似 S3 布局的本地文件夹中读取元数据。
## 架构设计
PLONDS 有意围绕单一的 C# 实现栈构建,以确保协议和发布行为不会在不同语言之间产生偏差。
```text
宿主应用
-> 检查更新、下载对象、暂存传入的负载
启动器
-> 验证签名、应用文件映射、切换部署、回滚
PLONDS.Api
-> 面向客户端的只读元数据投影
PLONDS.Tool
-> CI/发布命令界面
PLONDS.Core
-> 哈希/差异/对象仓库/签名/发布实现
PLONDS.Shared
-> 协议常量和 DTO
```
## v1 规则
- 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。
- `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。
- 宿主应用保留下载职责。
- 启动器保留应用、原子切换、快照和回滚职责。
## 存储布局
第一版本保持固定的对象根目录:
```text
lanmountain/update/
repo/sha256/<前缀>/<哈希>
meta/channels/<频道>/<平台>/latest.json
meta/distributions/<分发ID>.json
installers/<平台>/<版本>/...
```
已规划但 v1 中未启用:
```text
lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希>
lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希>
```
## 公共接口
API 基础路径为 `/api/plonds/v1`
- `GET /healthz` - 健康检查
- `GET /api/plonds/v1/metadata` - 获取元数据目录
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本
- `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息
## 本地运行
```powershell
dotnet run --project src/Plonds.Api
```
默认情况下API 从 `sample-data` 读取元数据。

View File

@@ -0,0 +1,10 @@
{
"channel": "stable",
"platform": "linux-x64",
"distributionId": "plonds-0.8.5.2-linux-x64",
"version": "0.8.5.2",
"publishedAt": "2026-04-20T00:00:00Z",
"distributionPath": "meta/distributions/plonds-0.8.5.2-linux-x64.json",
"fileMapPath": "meta/distributions/plonds-0.8.5.2-linux-x64.json"
}

View File

@@ -0,0 +1,10 @@
{
"channel": "stable",
"platform": "windows-x64",
"distributionId": "plonds-0.8.5.2-windows-x64",
"version": "0.8.5.2",
"publishedAt": "2026-04-20T00:00:00Z",
"distributionPath": "meta/distributions/plonds-0.8.5.2-windows-x64.json",
"fileMapPath": "meta/distributions/plonds-0.8.5.2-windows-x64.json"
}

View File

@@ -0,0 +1,10 @@
{
"channel": "stable",
"platform": "windows-x86",
"distributionId": "plonds-0.8.5.2-windows-x86",
"version": "0.8.5.2",
"publishedAt": "2026-04-20T00:00:00Z",
"distributionPath": "meta/distributions/plonds-0.8.5.2-windows-x86.json",
"fileMapPath": "meta/distributions/plonds-0.8.5.2-windows-x86.json"
}

View File

@@ -0,0 +1,66 @@
{
"distributionId": "plonds-0.8.5.2-linux-x64",
"version": "0.8.5.2",
"channel": "stable",
"platform": "linux-x64",
"publishedAt": "2026-04-20T00:00:00Z",
"components": [
{
"id": "app",
"root": "app-0.8.5.2/",
"mode": "file-object",
"metadata": {
"allowDiffUpdate": "true"
},
"files": [
{
"path": "LanMountainDesktop",
"op": "replace",
"contentHash": "sha256-placeholder-lanmountain-linux",
"size": 2048000,
"mode": "file-object",
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-linux"
}
]
},
{
"id": "installers",
"root": "installers/linux-x64/",
"mode": "file-object",
"files": [
{
"path": "LanMountainDesktop-0.8.5.2-linux-x64.deb",
"op": "add",
"contentHash": "sha256-placeholder-linux-x64-installer",
"size": 3096576,
"mode": "file-object",
"objectKey": "installers/linux-x64/LanMountainDesktop-0.8.5.2-linux-x64.deb"
}
]
}
],
"installerMirrors": [
{
"platform": "linux",
"arch": "x64",
"url": "https://downloads.example.invalid/lanmountain/linux-x64/LanMountainDesktop-0.8.5.2-linux-x64.deb",
"fileName": "LanMountainDesktop-0.8.5.2-linux-x64.deb"
}
],
"capabilities": [
"file-object",
"compressed-object",
"binary-patch"
],
"signatures": [
{
"algorithm": "rsa-sha256",
"keyId": "lanmountain-main",
"signature": "placeholder-signature"
}
],
"metadata": {
"notes": "sample distribution for PLONDS skeleton"
}
}

View File

@@ -0,0 +1,66 @@
{
"distributionId": "plonds-0.8.5.2-windows-x64",
"version": "0.8.5.2",
"channel": "stable",
"platform": "windows-x64",
"publishedAt": "2026-04-20T00:00:00Z",
"components": [
{
"id": "app",
"root": "app-0.8.5.2/",
"mode": "file-object",
"metadata": {
"allowDiffUpdate": "true"
},
"files": [
{
"path": "LanMountainDesktop.exe",
"op": "replace",
"contentHash": "sha256-placeholder-lanmountain-exe",
"size": 1024000,
"mode": "file-object",
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-exe"
}
]
},
{
"id": "installers",
"root": "installers/windows-x64/",
"mode": "file-object",
"files": [
{
"path": "LanMountainDesktop-Setup-0.8.5.2-x64.exe",
"op": "add",
"contentHash": "sha256-placeholder-windows-x64-installer",
"size": 2048000,
"mode": "file-object",
"objectKey": "installers/windows-x64/LanMountainDesktop-Setup-0.8.5.2-x64.exe"
}
]
}
],
"installerMirrors": [
{
"platform": "windows",
"arch": "x64",
"url": "https://downloads.example.invalid/lanmountain/windows-x64/LanMountainDesktop-Setup-0.8.5.2-x64.exe",
"fileName": "LanMountainDesktop-Setup-0.8.5.2-x64.exe"
}
],
"capabilities": [
"file-object",
"compressed-object",
"binary-patch"
],
"signatures": [
{
"algorithm": "rsa-sha256",
"keyId": "lanmountain-main",
"signature": "placeholder-signature"
}
],
"metadata": {
"notes": "sample distribution for PLONDS skeleton"
}
}

View File

@@ -0,0 +1,66 @@
{
"distributionId": "plonds-0.8.5.2-windows-x86",
"version": "0.8.5.2",
"channel": "stable",
"platform": "windows-x86",
"publishedAt": "2026-04-20T00:00:00Z",
"components": [
{
"id": "app",
"root": "app-0.8.5.2/",
"mode": "file-object",
"metadata": {
"allowDiffUpdate": "true"
},
"files": [
{
"path": "LanMountainDesktop.exe",
"op": "replace",
"contentHash": "sha256-placeholder-lanmountain-exe-x86",
"size": 983040,
"mode": "file-object",
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-exe-x86"
}
]
},
{
"id": "installers",
"root": "installers/windows-x86/",
"mode": "file-object",
"files": [
{
"path": "LanMountainDesktop-Setup-0.8.5.2-x86.exe",
"op": "add",
"contentHash": "sha256-placeholder-windows-x86-installer",
"size": 1982464,
"mode": "file-object",
"objectKey": "installers/windows-x86/LanMountainDesktop-Setup-0.8.5.2-x86.exe"
}
]
}
],
"installerMirrors": [
{
"platform": "windows",
"arch": "x86",
"url": "https://downloads.example.invalid/lanmountain/windows-x86/LanMountainDesktop-Setup-0.8.5.2-x86.exe",
"fileName": "LanMountainDesktop-Setup-0.8.5.2-x86.exe"
}
],
"capabilities": [
"file-object",
"compressed-object",
"binary-patch"
],
"signatures": [
{
"algorithm": "rsa-sha256",
"keyId": "lanmountain-main",
"signature": "placeholder-signature"
}
],
"metadata": {
"notes": "sample distribution for PLONDS skeleton"
}
}

View File

@@ -0,0 +1,11 @@
namespace Plonds.Api.Configuration;
public sealed class PlondsApiOptions
{
public string StorageRoot { get; set; } = Plonds.Shared.PlondsConstants.DefaultStorageRoot;
public string MetaRoot { get; set; } = Plonds.Shared.PlondsConstants.DefaultMetaRoot;
public string ApiBasePath { get; set; } = Plonds.Shared.PlondsConstants.DefaultApiBasePath;
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>Plonds.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,86 @@
using Microsoft.Extensions.Options;
using Plonds.Api.Configuration;
using Plonds.Api.Services;
using Plonds.Shared;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<PlondsApiOptions>(builder.Configuration.GetSection("Plonds"));
builder.Services.AddSingleton(sp =>
{
var options = sp.GetRequiredService<IOptions<PlondsApiOptions>>().Value;
return options;
});
builder.Services.AddSingleton<IPlondsManifestStore>(sp =>
{
var options = sp.GetRequiredService<PlondsApiOptions>();
return new FileSystemPlondsManifestStore(options);
});
var app = builder.Build();
var apiBasePath = app.Configuration["Plonds:ApiBasePath"];
if (string.IsNullOrWhiteSpace(apiBasePath))
{
apiBasePath = PlondsConstants.DefaultApiBasePath;
}
if (!apiBasePath.StartsWith('/'))
{
apiBasePath = "/" + apiBasePath;
}
app.MapGet("/healthz", () => Results.Ok(new { status = "ok", protocol = PlondsConstants.ProtocolName, version = PlondsConstants.ProtocolVersion }));
app.MapGet($"{apiBasePath}/metadata", async (IPlondsManifestStore store, CancellationToken cancellationToken) =>
{
var catalog = await store.GetCatalogAsync(cancellationToken);
return Results.Ok(catalog);
});
app.MapGet($"{apiBasePath}/channels/{{channel}}/{{platform}}/latest", async (
string channel,
string platform,
string? currentVersion,
IPlondsManifestStore store,
CancellationToken cancellationToken) =>
{
var latest = await store.GetLatestAsync(channel, platform, cancellationToken);
if (latest is null)
{
return Results.NotFound(new
{
error = "latest_pointer_not_found",
channel,
platform
});
}
if (!string.IsNullOrWhiteSpace(currentVersion) &&
Version.TryParse(currentVersion, out var current) &&
Version.TryParse(latest.Version, out var target) &&
target <= current)
{
return Results.NoContent();
}
return Results.Ok(latest);
});
app.MapGet($"{apiBasePath}/distributions/{{distributionId}}", async (string distributionId, IPlondsManifestStore store, CancellationToken cancellationToken) =>
{
var distribution = await store.GetDistributionAsync(distributionId, cancellationToken);
if (distribution is null)
{
return Results.NotFound(new
{
error = "distribution_not_found",
distributionId
});
}
return Results.Ok(distribution);
});
app.Run();

View File

@@ -0,0 +1,138 @@
using System.Text.Json;
using Plonds.Api.Configuration;
using Plonds.Shared;
using Plonds.Shared.Models;
namespace Plonds.Api.Services;
public sealed class FileSystemPlondsManifestStore : IPlondsManifestStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly PlondsApiOptions _options;
private readonly string _storageRootFullPath;
private readonly string _metaRootFullPath;
public FileSystemPlondsManifestStore(PlondsApiOptions options)
{
_options = options;
_storageRootFullPath = ResolveRootPath(options.StorageRoot);
_metaRootFullPath = Path.Combine(_storageRootFullPath, options.MetaRoot);
}
public Task<PlondsMetadataCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
_ = cancellationToken;
var channelsRoot = Path.Combine(_metaRootFullPath, "channels");
var latest = new List<PlondsChannelPointer>();
if (Directory.Exists(channelsRoot))
{
foreach (var latestPath in Directory.EnumerateFiles(channelsRoot, "latest.json", SearchOption.AllDirectories))
{
var pointer = ReadLatestPointer(latestPath);
if (pointer is not null)
{
latest.Add(pointer);
}
}
}
var catalog = new PlondsMetadataCatalog(
ProtocolName: PlondsConstants.ProtocolName,
ProtocolVersion: PlondsConstants.ProtocolVersion,
StorageRoot: _storageRootFullPath,
MetaRoot: _metaRootFullPath,
Latest: latest.OrderBy(x => x.Channel, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Platform, StringComparer.OrdinalIgnoreCase)
.ToArray(),
Metadata: new Dictionary<string, string>
{
["apiBasePath"] = PlondsConstants.DefaultApiBasePath
});
return Task.FromResult(catalog);
}
public Task<PlondsChannelPointer?> GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return Task.FromResult(ReadLatestPointer(GetLatestPath(channel, platform)));
}
public Task<PlondsDistributionInfo?> GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
var path = GetDistributionPath(distributionId);
if (!File.Exists(path))
{
return Task.FromResult<PlondsDistributionInfo?>(null);
}
var json = File.ReadAllText(path);
var distribution = JsonSerializer.Deserialize<PlondsDistributionInfo>(json, JsonOptions);
return Task.FromResult(distribution);
}
private PlondsChannelPointer? ReadLatestPointer(string path)
{
if (!File.Exists(path))
{
return null;
}
var json = File.ReadAllText(path);
var pointer = JsonSerializer.Deserialize<PlondsChannelPointer>(json, JsonOptions);
return pointer;
}
private string GetLatestPath(string channel, string platform)
{
return Path.Combine(_metaRootFullPath, "channels", channel, platform, "latest.json");
}
private string GetDistributionPath(string distributionId)
{
return Path.Combine(_metaRootFullPath, "distributions", $"{distributionId}.json");
}
private static string ResolveRootPath(string root)
{
if (Path.IsPathRooted(root))
{
return Path.GetFullPath(root);
}
var candidates = new List<string>();
AddCandidateChain(candidates, Directory.GetCurrentDirectory(), root);
AddCandidateChain(candidates, AppContext.BaseDirectory, root);
foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (Directory.Exists(candidate))
{
return candidate;
}
}
return candidates.FirstOrDefault() ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, root));
}
private static void AddCandidateChain(ICollection<string> candidates, string? startDirectory, string relativeRoot)
{
var current = string.IsNullOrWhiteSpace(startDirectory)
? null
: Path.GetFullPath(startDirectory);
while (!string.IsNullOrWhiteSpace(current))
{
candidates.Add(Path.GetFullPath(Path.Combine(current, relativeRoot)));
current = Directory.GetParent(current)?.FullName;
}
}
}

View File

@@ -0,0 +1,13 @@
using Plonds.Shared.Models;
namespace Plonds.Api.Services;
public interface IPlondsManifestStore
{
Task<PlondsMetadataCatalog> GetCatalogAsync(CancellationToken cancellationToken = default);
Task<PlondsChannelPointer?> GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default);
Task<PlondsDistributionInfo?> GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
{
"Plonds": {
"StorageRoot": "sample-data",
"MetaRoot": "meta",
"ApiBasePath": "/api/plonds/v1"
}
}

View File

@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
namespace Plonds.Core.Publishing;
public sealed record DdssBuildOptions(
string ReleaseTag,
string AssetsDirectory,
string OutputRoot,
string PrivateKeyPath,
string Repository,
string? S3BaseUrl = null);

View File

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

View File

@@ -0,0 +1,235 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Plonds.Core.Publishing;
public static class PayloadUtilities
{
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public static void CreatePayloadZip(string sourceDirectory, string outputZipPath)
{
var resolvedSourceDirectory = Path.GetFullPath(sourceDirectory);
if (!Directory.Exists(resolvedSourceDirectory))
{
throw new DirectoryNotFoundException($"Payload source directory not found: {resolvedSourceDirectory}");
}
var resolvedOutputZipPath = Path.GetFullPath(outputZipPath);
var outputDirectory = Path.GetDirectoryName(resolvedOutputZipPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
if (File.Exists(resolvedOutputZipPath))
{
File.Delete(resolvedOutputZipPath);
}
using var archive = ZipFile.Open(resolvedOutputZipPath, ZipArchiveMode.Create);
foreach (var filePath in Directory.EnumerateFiles(resolvedSourceDirectory, "*", SearchOption.AllDirectories))
{
var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedSourceDirectory, filePath));
if (ShouldIgnore(relativePath))
{
continue;
}
archive.CreateEntryFromFile(filePath, relativePath, CompressionLevel.Optimal);
}
}
internal static void ExtractZip(string zipPath, string destinationDirectory)
{
var resolvedZipPath = Path.GetFullPath(zipPath);
if (!File.Exists(resolvedZipPath))
{
throw new FileNotFoundException("Payload archive not found.", resolvedZipPath);
}
EnsureCleanDirectory(destinationDirectory);
ZipFile.ExtractToDirectory(resolvedZipPath, destinationDirectory, overwriteFiles: true);
}
internal static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
{
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
return manifest;
}
var resolvedRoot = Path.GetFullPath(root);
foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories))
{
var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedRoot, filePath));
if (ShouldIgnore(relativePath))
{
continue;
}
var fileInfo = new FileInfo(filePath);
manifest[relativePath] = new FileFingerprint(
relativePath,
filePath,
ComputeSha256(filePath),
fileInfo.Length,
ResolveUnixFileMode(filePath));
}
return manifest;
}
internal static string CopyObject(string sourcePath, string objectsRoot, string sha256)
{
var normalizedSha256 = sha256.Trim().ToLowerInvariant();
var prefix = normalizedSha256[..Math.Min(2, normalizedSha256.Length)];
var relativePath = NormalizeRelativePath(Path.Combine(prefix, normalizedSha256));
var destinationPath = Path.Combine(objectsRoot, prefix, normalizedSha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.Copy(sourcePath, destinationPath, overwrite: true);
}
return relativePath;
}
internal static void EnsureCleanDirectory(string path)
{
var resolvedPath = Path.GetFullPath(path);
if (Directory.Exists(resolvedPath))
{
Directory.Delete(resolvedPath, recursive: true);
}
Directory.CreateDirectory(resolvedPath);
}
internal static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
internal static void WriteJson<T>(string path, T value)
{
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(value, JsonOptions);
File.WriteAllText(path, json, new UTF8Encoding(false));
}
internal static string NormalizeRelativePath(string value)
{
return value.Replace('\\', '/').TrimStart('/');
}
internal static string ResolveArch(string platform)
{
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
{
return "x86";
}
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
{
return "arm64";
}
return "x64";
}
internal static bool ShouldIgnore(string relativePath)
{
var normalized = NormalizeRelativePath(relativePath.Trim());
if (string.IsNullOrWhiteSpace(normalized))
{
return true;
}
return normalized.Equals(".current", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals(".partial", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals(".destroy", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith(".current/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith(".partial/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith(".destroy/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("logs/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("cache/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("snapshots/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("snapshot/", StringComparison.OrdinalIgnoreCase);
}
private static string? ResolveUnixFileMode(string path)
{
if (OperatingSystem.IsWindows())
{
return null;
}
try
{
var mode = File.GetUnixFileMode(path);
return Convert.ToString((int)mode, 8);
}
catch
{
return InferUnixFileMode(path);
}
}
private static string? InferUnixFileMode(string path)
{
if (!LooksExecutable(path))
{
return null;
}
return "755";
}
private static bool LooksExecutable(string path)
{
try
{
using var stream = File.OpenRead(path);
Span<byte> header = stackalloc byte[4];
var read = stream.Read(header);
if (read >= 4 &&
header[0] == 0x7F &&
header[1] == (byte)'E' &&
header[2] == (byte)'L' &&
header[3] == (byte)'F')
{
return true;
}
if (read >= 2 && header[0] == (byte)'#' && header[1] == (byte)'!')
{
return true;
}
}
catch
{
return false;
}
var extension = Path.GetExtension(path);
return string.IsNullOrWhiteSpace(extension) &&
!OperatingSystem.IsWindows() &&
Path.GetFileName(path).Contains("LanMountainDesktop", StringComparison.OrdinalIgnoreCase);
}
internal sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size, string? UnixFileMode);
}

View File

@@ -0,0 +1,13 @@
namespace Plonds.Core.Publishing;
public sealed record PlatformPublishResult(
string Platform,
string DistributionId,
string CurrentAppDirectory,
string? PreviousDirectory,
string PreviousVersion,
string FileMapPath,
string SignaturePath,
string DistributionPath,
string LatestPath,
IReadOnlyList<string> InstallerFiles);

View File

@@ -0,0 +1,14 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsDeltaBuildOptions(
string Platform,
string CurrentVersion,
string CurrentTag,
string CurrentPayloadZip,
string OutputRoot,
string PrivateKeyPath,
string Channel = "stable",
string? BaselineVersion = null,
string? BaselineTag = null,
string? BaselinePayloadZip = null,
bool IsFullPayload = false);

View File

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

View File

@@ -0,0 +1,228 @@
using Plonds.Core.Security;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsDeltaBuilder
{
private readonly RsaFileSigner _signer = new();
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
if (!File.Exists(currentPayloadZip))
{
throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip);
}
var baselinePayloadZip = string.IsNullOrWhiteSpace(options.BaselinePayloadZip)
? null
: Path.GetFullPath(options.BaselinePayloadZip);
if (!string.IsNullOrWhiteSpace(baselinePayloadZip) && !File.Exists(baselinePayloadZip))
{
throw new FileNotFoundException("Baseline payload zip not found.", baselinePayloadZip);
}
var outputRoot = Path.GetFullPath(options.OutputRoot);
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
var currentExtractRoot = Path.Combine(workRoot, "current");
var baselineExtractRoot = Path.Combine(workRoot, "baseline");
var objectsRoot = Path.Combine(workRoot, "objects");
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
var summaryRoot = Path.Combine(outputRoot, "platform-summaries");
Directory.CreateDirectory(releaseAssetsRoot);
Directory.CreateDirectory(summaryRoot);
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip);
if (useFullPayload)
{
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
}
else
{
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
}
PayloadUtilities.EnsureCleanDirectory(objectsRoot);
var previousManifest = useFullPayload
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot);
var updateAssetName = $"update-{options.Platform}.zip";
var fileMapAssetName = $"plonds-filemap-{options.Platform}.json";
var fileMapSignatureAssetName = fileMapAssetName + ".sig";
var distributionId = $"plonds-{options.CurrentVersion}-{options.Platform}";
var updateArchivePath = Path.Combine(releaseAssetsRoot, updateAssetName);
var fileMapPath = Path.Combine(releaseAssetsRoot, fileMapAssetName);
var fileMapSignaturePath = Path.Combine(releaseAssetsRoot, fileMapSignatureAssetName);
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["protocol"] = "PLONDS",
["channel"] = options.Channel,
["releaseTag"] = options.CurrentTag,
["baselineTag"] = options.BaselineTag ?? string.Empty,
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
["targetVersion"] = options.CurrentVersion,
["isFullPayload"] = useFullPayload ? "true" : "false"
};
var component = new ComponentDocument(
Name: "app",
Version: options.CurrentVersion,
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["component"] = "app",
["mode"] = "file-object"
},
Files: fileEntries);
var fileMap = new FileMapDocument(
FormatVersion: "1.0",
DistributionId: distributionId,
FromVersion: options.BaselineVersion ?? "0.0.0",
ToVersion: options.CurrentVersion,
Version: options.CurrentVersion,
Platform: options.Platform,
Arch: PayloadUtilities.ResolveArch(options.Platform),
Channel: options.Channel,
GeneratedAt: DateTimeOffset.UtcNow,
Metadata: metadata,
Components: [component],
Files: fileEntries);
PayloadUtilities.WriteJson(fileMapPath, fileMap);
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
var summary = new PlondsReleasePlatformEntry(
Platform: options.Platform,
DistributionId: distributionId,
BaselineTag: options.BaselineTag,
BaselineVersion: options.BaselineVersion ?? "0.0.0",
TargetVersion: options.CurrentVersion,
IsFullPayload: useFullPayload,
FilesZipAsset: $"files-{options.Platform}.zip",
UpdateZipAsset: updateAssetName,
FileMapAsset: fileMapAssetName,
FileMapSignatureAsset: fileMapSignatureAssetName,
Sha256: PayloadUtilities.ComputeSha256(updateArchivePath));
var summaryPath = Path.Combine(summaryRoot, $"platform-summary-{options.Platform}.json");
PayloadUtilities.WriteJson(summaryPath, summary);
return new PlondsDeltaBuildResult(
options.Platform,
distributionId,
updateArchivePath,
fileMapPath,
fileMapSignaturePath,
summaryPath,
useFullPayload,
options.BaselineTag,
options.BaselineVersion,
options.CurrentVersion);
}
private static List<FileEntryDocument> BuildFileEntries(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string objectsRoot)
{
var result = new List<FileEntryDocument>();
foreach (var path in currentManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
if (previousManifest.TryGetValue(path, out var previous) &&
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
{
result.Add(new FileEntryDocument(
Path: path,
Action: "reuse",
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: null,
ObjectKey: null,
Metadata: null));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["mode"] = "file-object"
};
if (!string.IsNullOrWhiteSpace(current.UnixFileMode))
{
metadata["unixFileMode"] = current.UnixFileMode!;
}
result.Add(new FileEntryDocument(
Path: path,
Action: action,
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: objectPath,
ObjectKey: objectPath,
Metadata: metadata));
}
foreach (var path in previousManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
{
if (currentManifest.ContainsKey(path))
{
continue;
}
result.Add(new FileEntryDocument(
Path: path,
Action: "delete",
Sha256: string.Empty,
Size: 0,
ObjectPath: null,
ObjectKey: null,
Metadata: null));
}
return result;
}
private sealed record FileMapDocument(
string FormatVersion,
string DistributionId,
string FromVersion,
string ToVersion,
string Version,
string Platform,
string Arch,
string Channel,
DateTimeOffset GeneratedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<FileEntryDocument> Files);
private sealed record ComponentDocument(
string Name,
string Version,
IReadOnlyDictionary<string, string>? Metadata,
IReadOnlyList<FileEntryDocument> Files);
private sealed record FileEntryDocument(
string Path,
string Action,
string Sha256,
long Size,
string? ObjectPath,
string? ObjectKey,
IReadOnlyDictionary<string, string>? Metadata);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record DdssManifest(
string FormatVersion,
string ReleaseTag,
DateTimeOffset GeneratedAt,
IReadOnlyList<DdssAssetEntry> Assets);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
namespace Plonds.Shared.Models;
public sealed record PlondsFileEntry(
string Path,
string Op,
string ContentHash,
long Size,
string Mode,
string? ObjectKey = null,
string? Compression = null,
string? PatchBaseHash = null,
string? PatchObjectKey = null);

View File

@@ -0,0 +1,13 @@
namespace Plonds.Shared.Models;
public sealed record PlondsFileMap(
string FormatVersion,
string DistributionId,
string SourceVersion,
string TargetVersion,
string Platform,
IReadOnlyList<PlondsComponent> Components,
IReadOnlyList<string> Capabilities,
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Plonds.Shared</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,25 @@
namespace Plonds.Shared;
public static class PlondsConstants
{
public const string ProtocolName = "PLONDS";
public const string ProtocolVersion = "1.0";
public const string DefaultApiBasePath = "/api/plonds/v1";
public const string DefaultStorageRoot = "sample-data";
public const string DefaultMetaRoot = "meta";
public const string DefaultRepoRoot = "repo";
public const string DefaultInstallersRoot = "installers";
public const string FileObjectMode = "file-object";
public const string CompressedObjectMode = "compressed-object";
public const string BinaryPatchMode = "binary-patch";
public static readonly string[] SupportedFileModes =
[
FileObjectMode,
CompressedObjectMode,
BinaryPatchMode
];
}

View File

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

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Plonds.Core\Plonds.Core.csproj" />
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,221 @@
using Plonds.Core.Publishing;
using Plonds.Core.Security;
return await PlondsCli.RunAsync(args);
internal static class PlondsCli
{
public static Task<int> RunAsync(string[] args)
{
if (args.Length == 0)
{
PrintUsage();
return Task.FromResult(1);
}
var command = args[0].Trim().ToLowerInvariant();
var options = ParseOptions(args.Skip(1).ToArray());
try
{
switch (command)
{
case "generate":
RunGenerate(options);
return Task.FromResult(0);
case "sign":
RunSign(options);
return Task.FromResult(0);
case "publish":
RunPublish(options);
return Task.FromResult(0);
case "pack-payload":
RunPackPayload(options);
return Task.FromResult(0);
case "build-delta":
RunBuildDelta(options);
return Task.FromResult(0);
case "build-index":
RunBuildIndex(options);
return Task.FromResult(0);
case "build-ddss":
RunBuildDdss(options);
return Task.FromResult(0);
default:
Console.Error.WriteLine($"Unknown command: {command}");
PrintUsage();
return Task.FromResult(1);
}
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return Task.FromResult(1);
}
}
private static void RunGenerate(Dictionary<string, string> options)
{
var generator = new PlondsGenerator();
var result = generator.Generate(new PlondsGenerateOptions(
CurrentVersion: Require(options, "current-version"),
CurrentDirectory: Require(options, "current-dir"),
Platform: Require(options, "platform"),
OutputRoot: Require(options, "output-dir"),
PreviousVersion: Get(options, "previous-version", "0.0.0") ?? "0.0.0",
PreviousDirectory: Get(options, "previous-dir"),
Channel: Get(options, "channel", "stable") ?? "stable",
DistributionId: Get(options, "distribution-id"),
RepoBaseUrl: Get(options, "repo-base-url"),
FileMapUrl: Get(options, "file-map-url"),
FileMapSignatureUrl: Get(options, "file-map-signature-url"),
InstallerDirectory: Get(options, "installer-directory"),
InstallerBaseUrl: Get(options, "installer-base-url")));
Console.WriteLine($"Generated PLONDS artifacts for {result.Platform}: {result.DistributionId}");
Console.WriteLine(result.FileMapPath);
}
private static void RunSign(Dictionary<string, string> options)
{
var signer = new RsaFileSigner();
var signaturePath = signer.SignFile(
Require(options, "manifest"),
Require(options, "private-key"),
Get(options, "output"));
Console.WriteLine(signaturePath);
}
private static void RunPublish(Dictionary<string, string> options)
{
var publisher = new PlondsPublisher();
var results = publisher.Publish(new PlondsPublishOptions(
Version: Require(options, "version"),
AppArtifactsRoot: Require(options, "app-artifacts-root"),
InstallerArtifactsRoot: Require(options, "installer-artifacts-root"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineRoot: Get(options, "baseline-root"),
RepoBaseUrl: Get(options, "repo-base-url"),
InstallerBaseUrl: Get(options, "installer-base-url"),
IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload",
BaselineVersion: Get(options, "baseline-version"),
BaselineRef: Get(options, "baseline-ref"),
SourceCommit: Get(options, "source-commit"),
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease,
CommitRangeStart: Get(options, "commit-range-start"),
CommitRangeEnd: Get(options, "commit-range-end")));
foreach (var result in results)
{
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
}
}
private static void RunPackPayload(Dictionary<string, string> options)
{
var sourceDirectory = Require(options, "source-dir");
var outputZip = Require(options, "output-zip");
PayloadUtilities.CreatePayloadZip(sourceDirectory, outputZip);
Console.WriteLine(outputZip);
}
private static void RunBuildDelta(Dictionary<string, string> options)
{
var builder = new PlondsDeltaBuilder();
var result = builder.Build(new PlondsDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentTag: Require(options, "current-tag"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineVersion: Get(options, "baseline-version"),
BaselineTag: Get(options, "baseline-tag"),
BaselinePayloadZip: Get(options, "baseline-zip"),
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload));
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
Console.WriteLine(result.FileMapPath);
}
private static void RunBuildIndex(Dictionary<string, string> options)
{
var builder = new PlondsReleaseIndexBuilder();
var manifestPath = builder.Build(new PlondsReleaseIndexOptions(
ReleaseTag: Require(options, "release-tag"),
Version: Require(options, "version"),
Channel: Get(options, "channel", "stable") ?? "stable",
PlatformSummariesDirectory: Require(options, "platform-summaries-dir"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key")));
Console.WriteLine(manifestPath);
}
private static void RunBuildDdss(Dictionary<string, string> options)
{
var builder = new DdssManifestBuilder();
var manifestPath = builder.Build(new DdssBuildOptions(
ReleaseTag: Require(options, "release-tag"),
AssetsDirectory: Require(options, "assets-dir"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Repository: Require(options, "repository"),
S3BaseUrl: Get(options, "s3-base-url")));
Console.WriteLine(manifestPath);
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < args.Length; index++)
{
var token = args[index];
if (!token.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = token[2..];
var value = index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal)
? args[++index]
: "true";
result[key] = value;
}
return result;
}
private static string Require(IReadOnlyDictionary<string, string> options, string key)
{
if (options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
throw new InvalidOperationException($"Missing required option --{key}");
}
private static string? Get(IReadOnlyDictionary<string, string> options, string key, string? defaultValue = null)
{
return options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: defaultValue;
}
private static void PrintUsage()
{
Console.WriteLine("PLONDS Tool");
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-ddss --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
}
}

View File

@@ -194,3 +194,9 @@ This repository is organized around a desktop host app plus a host-side plugin e
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
## VeloPack Integration Note
- Incremental package build/publish has moved to VeloPack native assets (
eleases.win.json + *.nupkg).
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.

View File

@@ -166,3 +166,10 @@ Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.
## VeloPack Release Assets
- Windows incremental release packaging now uses VeloPack native outputs (
eleases.win.json, *.nupkg).
- Launcher still performs update apply/rollback; VeloPack is used for package generation.
- Legacy delta script flow is retained behind a disabled fallback switch in CI.

View File

@@ -442,3 +442,10 @@ private static void EnsurePathWithinRoot(string targetPath, string rootPath)
- [Launcher 架构文档](LAUNCHER.md)
- [构建和部署指南](BUILD_AND_DEPLOY.md)
- [故障排除指南](TROUBLESHOOTING.md)
## VeloPack Packaging (Current)
- Release pipeline now produces VeloPack native assets (
eleases.win.json, *.nupkg, RELEASES).
- Launcher remains the installer and rollback authority; only package generation moved to VeloPack.
- Legacy iles.json + update.zip generation remains available only as a disabled fallback path in CI.

32
phainon.yml Normal file
View File

@@ -0,0 +1,32 @@
# Phainon Distribution Center Client Configuration
name: "LanMountainDesktop"
components:
app:
allowDiffUpdate: true
root: "app-$(version)/"
includes:
- "**"
launcher:
root: ""
includes:
- "**"
excludes:
- "app*/**"
- "files*.json"
- "files*.json.sig"
- "update*.zip"
variables:
number: 0
fileRepoRoot: "__FILE_REPO_ROOT__"
archiveRoot: "__ARCHIVE_ROOT__/$(primaryVersion)/$(version)/"
bucketKeyRoot: "lanmountain/update/repo/"
archiveBucketKeyRoot: "lanmountain/update/archive/$(primaryVersion)/$(version)/"
appChangeLogPath: "$(thisFileDir)/../CHANGELOG.md"
appChangeLogTemplate: |
$(changeLog)
---
## Checksums And Downloads
$(hashes)

View File

@@ -1,105 +1,165 @@
# Generate-DeltaPackage.ps1
# 生成增量更新包 (delta.zip + files.json)
param(
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$PreviousVersion,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$CurrentVersion,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$PreviousDir,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$CurrentDir,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$OutputDir
)
$ErrorActionPreference = "Stop"
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
Write-Host "从版本: $PreviousVersion"
Write-Host "到版本: $CurrentVersion"
Write-Host "上一版本目录: $PreviousDir"
Write-Host "当前版本目录: $CurrentDir"
Write-Host "输出目录: $OutputDir"
Write-Host ""
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
# 确保输出目录存在
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
function Get-NormalizedRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$RootDir,
# 计算文件 SHA256
function Get-FileSha256 {
param([string]$Path)
$hash = Get-FileHash -Path $Path -Algorithm SHA256
return $hash.Hash.ToLower()
[Parameter(Mandatory = $true)]
[string]$FullPath
)
$separator = [System.IO.Path]::DirectorySeparatorChar
$altSeparator = [System.IO.Path]::AltDirectorySeparatorChar
$root = [System.IO.Path]::GetFullPath($RootDir).Replace($altSeparator, $separator).TrimEnd($separator)
$path = [System.IO.Path]::GetFullPath($FullPath).Replace($altSeparator, $separator)
$comparison = if ($separator -eq '\') {
[System.StringComparison]::OrdinalIgnoreCase
}
else {
[System.StringComparison]::Ordinal
}
$rootWithSeparator = "$root$separator"
if ($path.StartsWith($rootWithSeparator, $comparison)) {
$relative = $path.Substring($rootWithSeparator.Length)
}
elseif ($path.Equals($root, $comparison)) {
$relative = ""
}
else {
throw "File path '$path' is not under root '$root'."
}
return $relative.Replace('\', '/')
}
function Get-FileSha256Hex {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
# 获取目录中所有文件的相对路径和哈希
function Get-FileManifest {
param([string]$RootDir)
param(
[Parameter(Mandatory = $true)]
[string]$RootDir
)
if (-not (Test-Path -LiteralPath $RootDir)) {
throw "Directory does not exist: $RootDir"
}
$resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path
$manifest = @{}
$files = Get-ChildItem -Path $RootDir -Recurse -File
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
foreach ($file in $files) {
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
$relativePath = $relativePath.Replace('\', '/')
$manifest[$relativePath] = @{
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
$manifest[$relativePath] = [ordered]@{
Path = $relativePath
Sha256 = Get-FileSha256 -Path $file.FullName
Size = $file.Length
Sha256 = Get-FileSha256Hex -Path $file.FullName
Size = [long]$file.Length
}
}
return $manifest
}
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
if (-not (Test-Path $PreviousDir)) {
throw "Previous directory does not exist: $PreviousDir"
function New-DeltaArchive {
param(
[Parameter(Mandatory = $true)]
[string]$ZipPath,
[Parameter(Mandatory = $true)]
[string]$CurrentRoot,
[Parameter(Mandatory = $false)]
[AllowEmptyCollection()]
[object[]]$ChangedFiles = @()
)
if (Test-Path -LiteralPath $ZipPath) {
Remove-Item -LiteralPath $ZipPath -Force
}
$zip = [System.IO.Compression.ZipFile]::Open($ZipPath, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($file in $ChangedFiles) {
$sourcePath = Join-Path $CurrentRoot $file.Path
if (-not (Test-Path -LiteralPath $sourcePath)) {
throw "Changed file was not found while building archive: $sourcePath"
}
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip,
$sourcePath,
$file.Path,
[System.IO.Compression.CompressionLevel]::Optimal
) | Out-Null
}
}
finally {
$zip.Dispose()
}
}
Write-Host "Generating incremental package..."
Write-Host "From: $PreviousVersion"
Write-Host "To: $CurrentVersion"
Write-Host "Prev: $PreviousDir"
Write-Host "Curr: $CurrentDir"
Write-Host "Out: $OutputDir"
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
$previousManifest = Get-FileManifest -RootDir $PreviousDir
Write-Host " 找到 $($previousManifest.Count) 个文件" -ForegroundColor Gray
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
Write-Host " 目录: $CurrentDir" -ForegroundColor Gray
if (-not (Test-Path $CurrentDir)) {
throw "Current directory does not exist: $CurrentDir"
}
$currentManifest = Get-FileManifest -RootDir $CurrentDir
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
# 分析文件变更
$changedFiles = @()
$reusedFiles = @()
$deletedFiles = @()
Write-Host "分析文件变更..." -ForegroundColor Yellow
# 检查新增和修改的文件
foreach ($path in $currentManifest.Keys) {
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
$currentFile = $currentManifest[$path]
if ($previousManifest.ContainsKey($path)) {
$previousFile = $previousManifest[$path]
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
# 文件未变更,可以复用
$reusedFiles += @{
$reusedFiles += [ordered]@{
Path = $path
Action = "reuse"
Sha256 = $currentFile.Sha256
Size = $currentFile.Size
}
} else {
# 文件已修改
$changedFiles += @{
}
else {
$changedFiles += [ordered]@{
Path = $path
Action = "replace"
Sha256 = $currentFile.Sha256
@@ -107,9 +167,9 @@ foreach ($path in $currentManifest.Keys) {
ArchivePath = $path
}
}
} else {
# 新增文件
$changedFiles += @{
}
else {
$changedFiles += [ordered]@{
Path = $path
Action = "add"
Sha256 = $currentFile.Sha256
@@ -119,104 +179,51 @@ foreach ($path in $currentManifest.Keys) {
}
}
# 检查删除的文件
foreach ($path in $previousManifest.Keys) {
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
if (-not $currentManifest.ContainsKey($path)) {
$deletedFiles += @{
$deletedFiles += [ordered]@{
Path = $path
Action = "delete"
}
}
}
Write-Host "变更统计:" -ForegroundColor Green
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
Write-Host " 复用: $($reusedFiles.Count) 个文件"
Write-Host " 删除: $($deletedFiles.Count) 个文件"
Write-Host ""
Write-Host "Changed: $($changedFiles.Count)"
Write-Host "Reused: $($reusedFiles.Count)"
Write-Host "Deleted: $($deletedFiles.Count)"
# 显示前10个变更的文件用于调试
if ($changedFiles.Count -gt 0) {
Write-Host "变更的文件示例:" -ForegroundColor Cyan
$changedFiles | Select-Object -First 10 | ForEach-Object {
Write-Host " [$($_.Action)] $($_.Path)" -ForegroundColor Gray
}
if ($changedFiles.Count -gt 10) {
Write-Host " ... 还有 $($changedFiles.Count - 10) 个文件" -ForegroundColor Gray
}
Write-Host ""
}
# 创建临时目录用于打包
$tempDir = Join-Path $OutputDir "temp_delta"
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
# 复制变更的文件到临时目录
Write-Host "复制变更文件..." -ForegroundColor Yellow
foreach ($file in $changedFiles) {
$sourcePath = Join-Path $CurrentDir $file.Path
$destPath = Join-Path $tempDir $file.Path
$destDir = Split-Path -Parent $destPath
if (-not (Test-Path $destDir)) {
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
}
Copy-Item -Path $sourcePath -Destination $destPath -Force
}
# 创建 update.zip (Launcher 期望的文件名)
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
$updateZipPath = Join-Path $OutputDir "update.zip"
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
if (Test-Path $updateZipPath) {
Remove-Item -Path $updateZipPath -Force
}
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
# 同时创建带版本号的副本(用于发布到 GitHub Release
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
Write-Host "创建带版本号的副本: $deltaZipPath" -ForegroundColor Yellow
if (Test-Path $deltaZipPath) {
Remove-Item -Path $deltaZipPath -Force
}
Copy-Item -Path $updateZipPath -Destination $deltaZipPath -Force
# 清理临时目录
Remove-Item -Path $tempDir -Recurse -Force
# 生成 files.json (Launcher 期望的文件名)
$filesJson = @{
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
$filesJson = [ordered]@{
FromVersion = $PreviousVersion
ToVersion = $CurrentVersion
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
Files = @($changedFiles + $reusedFiles + $deletedFiles)
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
Files = $allEntries
}
$jsonText = $filesJson | ConvertTo-Json -Depth 10
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$filesJsonPath = Join-Path $OutputDir "files.json"
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
[System.IO.File]::WriteAllText($filesJsonPath, $jsonText, $utf8NoBom)
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
$versionedFilesJsonPath = Join-Path $OutputDir ("files-{0}.json" -f $CurrentVersion)
Copy-Item -LiteralPath $filesJsonPath -Destination $versionedFilesJsonPath -Force
# 同时创建带版本号的副本(用于发布到 GitHub Release
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
# 计算增量包大小
$updateSize = (Get-Item $updateZipPath).Length
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
Write-Host ""
Write-Host "=== 完成 ===" -ForegroundColor Green
Write-Host "增量包大小: $updateSizeMB MB"
Write-Host "输出文件 (Launcher 使用):"
Write-Host " - $updateZipPath"
Write-Host " - $filesJsonPath"
Write-Host "输出文件 (GitHub Release 发布):"
Write-Host " - $deltaZipPath"
Write-Host " - $versionedFilesJsonPath"
Write-Host "Done."
Write-Host "update.zip size: $updateSizeMb MB"
Write-Host "Generated:"
Write-Host " $updateZipPath"
Write-Host " $filesJsonPath"
Write-Host " $deltaZipPath"
Write-Host " $versionedFilesJsonPath"

View File

@@ -0,0 +1,87 @@
param(
[Parameter(Mandatory = $true)]
[string]$CurrentVersion,
[Parameter(Mandatory = $true)]
[string]$CurrentDir,
[Parameter(Mandatory = $true)]
[string]$Platform,
[Parameter(Mandatory = $true)]
[string]$OutputDir,
[Parameter(Mandatory = $false)]
[string]$PreviousVersion = "",
[Parameter(Mandatory = $false)]
[string]$PreviousDir = "",
[Parameter(Mandatory = $false)]
[string]$Channel = "stable",
[Parameter(Mandatory = $false)]
[string]$DistributionId = "",
[Parameter(Mandatory = $false)]
[string]$RepoBaseUrl = "",
[Parameter(Mandatory = $false)]
[string]$FileMapUrl = "",
[Parameter(Mandatory = $false)]
[string]$FileMapSignatureUrl = "",
[Parameter(Mandatory = $false)]
[string]$InstallerDirectory = "",
[Parameter(Mandatory = $false)]
[string]$InstallerBaseUrl = ""
)
$ErrorActionPreference = "Stop"
$toolProject = Join-Path $PSScriptRoot "..\PenguinLogisticsOnlineNetworkDistributionSystem\src\Plonds.Tool\Plonds.Tool.csproj"
if (-not (Test-Path -LiteralPath $toolProject)) {
throw "PLONDS tool project not found: $toolProject"
}
$arguments = @(
"run",
"--project", $toolProject,
"--",
"generate",
"--current-version", $CurrentVersion,
"--current-dir", $CurrentDir,
"--platform", $Platform,
"--output-dir", $OutputDir,
"--previous-version", $(if ([string]::IsNullOrWhiteSpace($PreviousVersion)) { "0.0.0" } else { $PreviousVersion }),
"--channel", $Channel
)
if (-not [string]::IsNullOrWhiteSpace($PreviousDir)) {
$arguments += @("--previous-dir", $PreviousDir)
}
if (-not [string]::IsNullOrWhiteSpace($DistributionId)) {
$arguments += @("--distribution-id", $DistributionId)
}
if (-not [string]::IsNullOrWhiteSpace($RepoBaseUrl)) {
$arguments += @("--repo-base-url", $RepoBaseUrl)
}
if (-not [string]::IsNullOrWhiteSpace($FileMapUrl)) {
$arguments += @("--file-map-url", $FileMapUrl)
}
if (-not [string]::IsNullOrWhiteSpace($FileMapSignatureUrl)) {
$arguments += @("--file-map-signature-url", $FileMapSignatureUrl)
}
if (-not [string]::IsNullOrWhiteSpace($InstallerDirectory)) {
$arguments += @("--installer-directory", $InstallerDirectory)
}
if (-not [string]::IsNullOrWhiteSpace($InstallerBaseUrl)) {
$arguments += @("--installer-base-url", $InstallerBaseUrl)
}
& dotnet @arguments
if ($LASTEXITCODE -ne 0) {
throw "PLONDS generate command failed."
}

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