Compare commits

..

2 Commits

Author SHA1 Message Date
lincube
5af7ac8b56 Normalize release artifacts before publishing 2026-04-21 21:19:04 +08:00
lincube
4cb52e56c7 Launcher (#4)
* 激进的更新

* 试试

* fix.可爱的我一直在修CI(

* fix.启动器一定要能够启动

* feat.尝试弄了AOT的启动器。

* fix.修CI,好像是因为Linux那边有个问题,反正修就对了。

* fix.ci难修,为什么liunx跑不起来呢?

* Update build.yml

* Update LanMountainDesktop.csproj

* changed.调整了启动逻辑,优化了更新页面。

* changed.优化了更新体验

* feat.依旧试增量更新这一块,看看velopack

* fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。

* fix.继续修ci,ci怎么天天炸

* changed.velopack,试试rust

* fix.修ci,修融合桌面,修启动器

* fix.GitHub Action工作流怎么天天出问题

* feat.引入velopack,不好,是rust(至少内存很安全了。

* chore: migrate release pipeline to signed filemap and wire rainyun s3

* fix: make optional s3 upload step workflow-parse safe

* fix: make delta pack generation robust for empty diffs and linux paths

* chore: rotate launcher update public key for pdc signing

* fix: restore stable launcher update public key

* fix: sync launcher public key with update signing secret

* fix: normalize PEM line endings in signing key validation

* fix: rotate launcher public key to match ci signing secret

* fix: compare signing keys by SPKI instead of PEM text

* refactor update backend to host-managed PDC pipeline

* fix release workflow env key collisions

* relax publish-pdc precheck to require S3 only

* set GH_TOKEN for PDCC installer step

* ci: add local pdc mock fallback for release publish

* ci: fix pdc mock process log redirection

* ci: fallback pdcc signing key to update private key

* ci: ensure pdcc signing passphrase env is always set

* ci: create pdcc publish root before invoking client

* ci: set pdcc version variable from release version

* ci: decouple pdcc installer version from publish config version

* ci: package pdcc subchannels with generated filemap and changelog

* ci: make local pdc mock diff return empty for fast fallback

* ci: fix pdcc variable mapping and pdc signing prechecks

* Update App.axaml.cs

* ci: wire aws cli credentials for rainyun s3

* ci: pin pdcc client version separately from app version

* ci: harden local pdc mock transport handling

* ci: publish pdcc subchannels in one pass

* ci: add pdcc publish heartbeat and timeout

* ci: fix pdcc publish workdir bootstrap

* feat.Penguin Logistics Online Network Distribution System

* ci: fix plonds s3 probe and signing fallback

* ci: validate signing key and quiet missing baselines

* ci: relax aws checksum mode for rainyun s3

* ci: avoid multipart uploads to rainyun s3

* ci: handle empty plonds baselines safely

* ci.plonds

* Rebuild release pipeline around PLONDS and DDSS

* Fix Windows installer script path in release workflow
2026-04-21 20:59:52 +08:00
68 changed files with 5397 additions and 1414 deletions

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
@@ -107,9 +136,6 @@ jobs:
$arch = "${{ matrix.arch }}"
$launcherPublishDir = "publish/launcher-win-$arch"
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT publish
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
@@ -120,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)"
}
# Warn if unexpected extra files are produced
$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
@@ -178,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
@@ -191,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
@@ -230,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
@@ -299,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"
@@ -312,25 +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: Upload App Payload
uses: actions/upload-artifact@v4
with:
name: app-payload-windows-${{ matrix.arch }}
path: |
publish/windows-${{ matrix.arch }}/**
if-no-files-found: error
retention-days: 30
- name: Package Payload Zip
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$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
}
- name: Upload Installer
$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
}
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
if (Test-Path $payloadZip) {
Remove-Item $payloadZip -Force
}
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
shell: pwsh
- name: Upload Release Assets
uses: actions/upload-artifact@v4
with:
name: installer-windows-${{ matrix.arch }}
path: build-installer/*.exe
name: release-windows-${{ matrix.arch }}
path: |
release-assets/files-windows-${{ matrix.arch }}.zip
build-installer/*.exe
if-no-files-found: error
retention-days: 30
@@ -355,13 +352,10 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
clang zlib1g-dev zip rsync
# 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
@@ -383,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 \
@@ -395,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: |
@@ -430,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
@@ -461,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"
@@ -475,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" \
@@ -512,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"/*
@@ -523,35 +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 App Payload
uses: actions/upload-artifact@v4
with:
name: app-payload-linux-x64
path: |
publish/linux-x64/**
if-no-files-found: error
retention-days: 30
rm -rf "$stage_dir"
mkdir -p "$stage_dir" "$release_dir"
rsync -a \
--exclude '.current' \
--exclude '.partial' \
--exclude '.destroy' \
"$payload_root/" "$stage_dir/"
- name: Upload Installer
(
cd "$stage_dir"
zip -qr "$release_dir/files-linux-x64.zip" .
)
- name: Upload Release Assets
uses: actions/upload-artifact@v4
with:
name: installer-linux-x64
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 }}
@@ -586,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 }} \
@@ -598,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: |
@@ -626,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 }}"
@@ -635,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">'
@@ -693,267 +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: installer-macos-${{ matrix.arch }}
path: "*.dmg"
if-no-files-found: error
name: release-macos-${{ matrix.arch }}
path: |
release-assets/files-macos-${{ matrix.arch }}.zip
*.dmg
if-no-files-found: ignore
retention-days: 30
publish-pdc:
needs: [ prepare, build-windows, build-linux, build-macos ]
runs-on: ubuntu-latest
permissions:
contents: read
env:
VERSION: ${{ needs.prepare.outputs.version }}
PRIMARY_VERSION: ${{ needs.prepare.outputs.version }}
PDCC_primaryVersion: ${{ needs.prepare.outputs.version }}
PDCC_VERSION: ${{ vars.PDC_CLIENT_VERSION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
PDC_ENDPOINT: ${{ vars.PDC_ENDPOINT }}
PDC_TOKEN: ${{ secrets.PDC_TOKEN }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Download payload artifacts
uses: actions/download-artifact@v4
with:
path: payload-artifacts
pattern: app-payload-*
- name: Download installer artifacts
uses: actions/download-artifact@v4
with:
path: installer-artifacts
pattern: installer-*
- name: Prepare PDC environment
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($env:S3_ENDPOINT) -or
[string]::IsNullOrWhiteSpace($env:S3_BUCKET)) {
throw "Missing required S3 variables."
}
if ([string]::IsNullOrWhiteSpace($env:PDC_SIGNING_KEY)) {
if ([string]::IsNullOrWhiteSpace($env:UPDATE_PRIVATE_KEY_PEM)) {
throw "Missing UPDATE_PRIVATE_KEY_PEM or PDC_SIGNING_KEY."
}
$env:PDC_SIGNING_KEY = $env:UPDATE_PRIVATE_KEY_PEM
}
$workRoot = Join-Path $PWD "pdc-work"
if (Test-Path $workRoot) {
Remove-Item -LiteralPath $workRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
$template = Get-Content -Path "phainon.yml" -Raw
$resolved = $template `
-replace '__FILE_REPO_ROOT__', "$($env:S3_ENDPOINT.TrimEnd('/'))/$($env:S3_BUCKET)/lanmountain/update/repo/" `
-replace '__ARCHIVE_ROOT__', "$($env:S3_ENDPOINT.TrimEnd('/'))/$($env:S3_BUCKET)/lanmountain/update/installers/"
Set-Content -Path (Join-Path $workRoot "phainon.resolved.yml") -Value $resolved -NoNewline
python3 -m pip install --user --upgrade awscli
Add-Content -Path $env:GITHUB_PATH -Value "$HOME/.local/bin"
- name: Install PDCC
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./scripts/Install-Pdcc.ps1 -Repository "ClassIsland/PhainonDistributionCenter" -OutputDir "./pdcc"
- name: Publish with PDCC
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# Map CI vars to the naming convention expected by PDCC tooling.
$env:S3_Endpoint = $env:S3_ENDPOINT
$env:S3_Bucket = $env:S3_BUCKET
$env:S3_Region = $env:S3_REGION
$env:PDC_Endpoint = $env:PDC_ENDPOINT
$env:PDC_Token = $env:PDC_TOKEN
$env:S3_AccessKey = $env:S3_ACCESS_KEY
$env:S3_SecretKey = $env:S3_SECRET_KEY
if ([string]::IsNullOrWhiteSpace($env:PDC_SigningKey)) {
$env:PDC_SigningKey = $env:PDC_SIGNING_KEY
}
$stageRoot = Join-Path $PWD "pdc-stage"
$payloadRoot = Join-Path $PWD "payload-artifacts"
$installerRoot = Join-Path $PWD "installer-artifacts"
$outRoot = Join-Path $PWD "pdc-output"
$client = Join-Path $PWD "pdcc/PhainonDistributionCenter.Client"
$config = Join-Path $PWD "pdc-work/phainon.resolved.yml"
if (Test-Path $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force
}
if (Test-Path $outRoot) {
Remove-Item -LiteralPath $outRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $stageRoot -Force | Out-Null
New-Item -ItemType Directory -Path $outRoot -Force | Out-Null
$payloadArtifacts = Get-ChildItem -LiteralPath $payloadRoot -Directory
if (-not $payloadArtifacts) {
throw "No payload artifacts were downloaded."
}
$installerArtifacts = Get-ChildItem -LiteralPath $installerRoot -Directory
if (-not $installerArtifacts) {
throw "No installer artifacts were downloaded."
}
foreach ($installerArtifact in $installerArtifacts) {
$stagedInstallerDir = Join-Path $stageRoot "installers/$($installerArtifact.Name)"
./scripts/Prepare-PdccOut.ps1 -SourceDir $installerArtifact.FullName -OutputDir $stagedInstallerDir
}
foreach ($payloadArtifact in $payloadArtifacts) {
$platformKey = $payloadArtifact.Name -replace '^app-payload-', ''
$stagedPayloadDir = Join-Path $stageRoot "payloads/$platformKey"
./scripts/Prepare-PdccOut.ps1 -SourceDir $payloadArtifact.FullName -OutputDir $stagedPayloadDir
$subChannel = ($platformKey -replace '-', '_') + "_release_folderClassic"
$env:PDC_SUBCHANNEL = $subChannel
Push-Location $stagedPayloadDir
try {
& $client $config Publish $env:PRIMARY_VERSION $env:VERSION (Join-Path $outRoot "published/$platformKey")
if ($LASTEXITCODE -ne 0) {
throw "PDCC Publish failed for $platformKey."
}
}
finally {
Pop-Location
}
}
if (Test-Path (Join-Path $stageRoot "installers")) {
aws --endpoint-url "$env:S3_ENDPOINT" s3 sync (Join-Path $stageRoot "installers") "s3://$env:S3_BUCKET/lanmountain/update/installers/" --only-show-errors
}
- name: Upload PDC Assets
uses: actions/upload-artifact@v4
with:
name: pdc-assets
path: |
pdc-output/published/**
if-no-files-found: error
retention-days: 90
github-release:
needs: [ prepare, build-windows, build-linux, build-macos, publish-pdc ]
needs: [prepare, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download installer artifacts
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/installers
pattern: installer-*
path: release-files
pattern: release-*
merge-multiple: true
- name: Download PDC artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/pdc
pattern: pdc-assets
- 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
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
find artifacts/pdc -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.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 Assets
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
Existing users: Launcher will detect platform-matching signed assets and apply update on next startup.
### 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 }}

View File

@@ -9,11 +9,11 @@ namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(PdcUpdateMetadata))]
[JsonSerializable(typeof(PdcFileMap))]
[JsonSerializable(typeof(PdcComponentEntry))]
[JsonSerializable(typeof(PdcFileEntry))]
[JsonSerializable(typeof(PdcHashDescriptor))]
[JsonSerializable(typeof(PlondsUpdateMetadata))]
[JsonSerializable(typeof(PlondsFileMap))]
[JsonSerializable(typeof(PlondsComponentEntry))]
[JsonSerializable(typeof(PlondsFileEntry))]
[JsonSerializable(typeof(PlondsHashDescriptor))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]

View File

@@ -54,7 +54,7 @@ internal sealed class UpdateApplyResult
public string? RolledBackTo { get; init; }
}
internal sealed class PdcUpdateMetadata
internal sealed class PlondsUpdateMetadata
{
public string? DistributionId { get; set; }
@@ -73,7 +73,7 @@ internal sealed class PdcUpdateMetadata
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PdcFileMap
internal sealed class PlondsFileMap
{
public string? DistributionId { get; set; }
@@ -89,12 +89,12 @@ internal sealed class PdcFileMap
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PdcComponentEntry> Components { get; set; } = [];
public List<PlondsComponentEntry> Components { get; set; } = [];
public List<PdcFileEntry> Files { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PdcComponentEntry
internal sealed class PlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
@@ -102,10 +102,10 @@ internal sealed class PdcComponentEntry
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PdcFileEntry> Files { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PdcFileEntry
internal sealed class PlondsFileEntry
{
public string Path { get; set; } = string.Empty;
@@ -129,12 +129,12 @@ internal sealed class PdcFileEntry
public byte[]? Sha512Bytes { get; set; }
public PdcHashDescriptor? Hash { get; set; }
public PlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PdcHashDescriptor
internal sealed class PlondsHashDescriptor
{
public string? Algorithm { get; set; }

View File

@@ -14,10 +14,10 @@ internal sealed class UpdateEngineService
private const string SignedFileMapName = "files.json";
private const string SignatureFileName = "files.json.sig";
private const string ArchiveFileName = "update.zip";
private const string PdcFileMapName = "pdc-filemap.json";
private const string PdcSignatureFileName = "pdc-filemap.sig";
private const string PdcUpdateMetadataName = "pdc-update.json";
private const string PdcObjectsDirectoryName = "objects";
private const string PlondsFileMapName = "plonds-filemap.json";
private const string PlondsSignatureFileName = "plonds-filemap.sig";
private const string PlondsUpdateMetadataName = "plonds-update.json";
private const string PlondsObjectsDirectoryName = "objects";
private const string PublicKeyFileName = "public-key.pem";
private readonly DeploymentLocator _deploymentLocator;
@@ -37,33 +37,33 @@ internal sealed class UpdateEngineService
public LauncherResult CheckPendingUpdate()
{
var pdcFileMapPath = Path.Combine(_incomingRoot, PdcFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PdcSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PdcUpdateMetadataName);
var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName);
if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
{
var pdcFileMapText = File.ReadAllText(pdcFileMapPath);
var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PdcFileMap);
var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap);
if (pdcFileMap is null)
{
return Failed("update.check", "invalid_manifest", "pdc-filemap.json is invalid.");
return Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid.");
}
var pdcVerified = VerifySignature(pdcFileMapPath, pdcSignaturePath, PdcSignatureFileName);
var pdcVerified = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
if (!pdcVerified.Success)
{
return Failed("update.check", "signature_failed", pdcVerified.Message);
}
var pdcMetadata = LoadPdcUpdateMetadata(pdcUpdatePath);
var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath);
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "available",
Message = "Pending PDC update is available.",
Message = "Pending PLONDS update is available.",
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
TargetVersion = ResolvePdcTargetVersion(pdcFileMap, pdcMetadata)
TargetVersion = ResolvePlondsTargetVersion(pdcFileMap, pdcMetadata)
};
}
@@ -126,12 +126,12 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(_incomingRoot);
Directory.CreateDirectory(_snapshotsRoot);
var pdcFileMapPath = Path.Combine(_incomingRoot, PdcFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PdcSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PdcUpdateMetadataName);
var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName);
if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
{
return await ApplyPendingPdcUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath);
return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath);
}
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
@@ -165,9 +165,7 @@ internal sealed class UpdateEngineService
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
// 全新安装场景:没有当前部署目录,但有更新包
// 这种情况下应该直接应用更新作为首次安装
return await ApplyInitialDeploymentAsync(fileMap, archivePath, fileMapPath, signaturePath);
// Initial install path: no current deployment exists, so apply the staged package directly.
}
var currentVersion = _deploymentLocator.GetCurrentVersion();
@@ -236,7 +234,7 @@ internal sealed class UpdateEngineService
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
// 清理旧版本但保留最近3个版本以支持回滚
// 婵炴挸鎳愰幃濠囧籍瑜忔晶妤呭嫉椤掑﹦绀夊ù锝呮缁绘岸鎮惧▎鎰粯閺?濞戞搩浜炴晶妤呭嫉椤戝じ绨伴柡鈧娑樼槷闁搞儳鍋炵划?
CleanupDestroyedDeployments();
return new LauncherResult
@@ -280,46 +278,46 @@ internal sealed class UpdateEngineService
}
}
private async Task<LauncherResult> ApplyPendingPdcUpdateAsync(
private async Task<LauncherResult> ApplyPendingPlondsUpdateAsync(
string pdcFileMapPath,
string pdcSignaturePath,
string pdcUpdatePath)
{
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PdcSignatureFileName);
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
if (!verifyResult.Success)
{
return Failed("update.apply", "signature_failed", verifyResult.Message);
}
var fileMapText = await File.ReadAllTextAsync(pdcFileMapPath).ConfigureAwait(false);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PdcFileMap) ?? new PdcFileMap();
var fileEntries = CollectPdcFileEntries(fileMap);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap();
var fileEntries = CollectPlondsFileEntries(fileMap);
if (fileEntries.Count == 0)
{
PopulatePdcManifestFromRawJson(fileMapText, fileMap, fileEntries);
PopulatePlondsManifestFromRawJson(fileMapText, fileMap, fileEntries);
}
if (fileEntries.Count == 0)
{
return Failed("update.apply", "invalid_manifest", "No PDC file entries were found.");
return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
}
var pdcMetadata = LoadPdcUpdateMetadata(pdcUpdatePath);
var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath);
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
var currentVersion = _deploymentLocator.GetCurrentVersion();
var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion;
var expectedSourceVersion = ResolvePdcSourceVersion(fileMap, pdcMetadata);
var expectedSourceVersion = ResolvePlondsSourceVersion(fileMap, pdcMetadata);
if (!string.IsNullOrWhiteSpace(expectedSourceVersion) &&
!string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase))
{
return Failed(
"update.apply",
"version_mismatch",
$"PDC update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
$"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
}
var targetVersion = ResolvePdcTargetVersion(fileMap, pdcMetadata);
var targetVersion = ResolvePlondsTargetVersion(fileMap, pdcMetadata);
if (string.IsNullOrWhiteSpace(targetVersion))
{
targetVersion = sourceVersion;
@@ -354,12 +352,12 @@ internal sealed class UpdateEngineService
foreach (var entry in fileEntries)
{
ApplyPdcFileEntry(entry, currentDeployment, targetDeployment);
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
}
foreach (var entry in fileEntries)
{
VerifyPdcFileEntry(entry, targetDeployment);
VerifyPlondsFileEntry(entry, targetDeployment);
}
if (isInitialDeployment)
@@ -412,7 +410,7 @@ internal sealed class UpdateEngineService
Success = false,
Stage = "update.apply",
Code = "initial_deploy_failed",
Message = "Failed to apply initial PDC deployment.",
Message = "Failed to apply initial PLONDS deployment.",
ErrorMessage = ex.Message,
CurrentVersion = "0.0.0",
TargetVersion = targetVersion
@@ -427,7 +425,7 @@ internal sealed class UpdateEngineService
Success = false,
Stage = "update.apply",
Code = "apply_failed",
Message = "Failed to apply PDC update. Rolled back to previous version.",
Message = "Failed to apply PLONDS update. Rolled back to previous version.",
ErrorMessage = ex.Message,
CurrentVersion = sourceVersion,
RolledBackTo = sourceVersion
@@ -435,7 +433,7 @@ internal sealed class UpdateEngineService
}
}
private void ApplyPdcFileEntry(PdcFileEntry file, string? currentDeployment, string targetDeployment)
private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment)
{
var normalizedPath = NormalizeRelativePath(file.Path);
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
@@ -467,16 +465,18 @@ internal sealed class UpdateEngineService
}
File.Copy(sourcePath, targetPath, overwrite: true);
ApplyUnixFileModeIfPresent(targetPath, file);
return;
}
var objectPath = ResolvePdcObjectPath(file);
var objectPath = ResolvePlondsObjectPath(file);
var objectBytes = File.ReadAllBytes(objectPath);
var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes;
File.WriteAllBytes(targetPath, restoredBytes);
ApplyUnixFileModeIfPresent(targetPath, file);
}
private void VerifyPdcFileEntry(PdcFileEntry file, string targetDeployment)
private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment)
{
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase))
@@ -512,26 +512,26 @@ internal sealed class UpdateEngineService
}
}
private string ResolvePdcObjectPath(PdcFileEntry file)
private string ResolvePlondsObjectPath(PlondsFileEntry file)
{
var candidates = new List<string>();
AddPdcPathCandidates(candidates, file.ObjectPath);
AddPdcPathCandidates(candidates, file.ObjectKey);
AddPdcPathCandidates(candidates, file.ArchivePath);
AddPdcPathCandidates(candidates, file.ObjectUrl);
AddPdcPathCandidates(candidates, file.Url);
AddPlondsPathCandidates(candidates, file.ObjectPath);
AddPlondsPathCandidates(candidates, file.ObjectKey);
AddPlondsPathCandidates(candidates, file.ArchivePath);
AddPlondsPathCandidates(candidates, file.ObjectUrl);
AddPlondsPathCandidates(candidates, file.Url);
if (TryGetExpectedObjectSha512(file, out var expectedSha512) || TryGetExpectedSha512(file, out expectedSha512))
{
var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant();
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex));
if (hashHex.Length > 2)
{
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex[..2], hashHex));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex));
// Backward compatibility for previously staged paths.
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex[..2], hashHex[2..]));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..]));
}
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, $"{hashHex}.gz"));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, $"{hashHex}.gz"));
}
foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
@@ -567,7 +567,7 @@ internal sealed class UpdateEngineService
}
}
private void AddPdcPathCandidates(ICollection<string> candidates, string? value)
private void AddPlondsPathCandidates(ICollection<string> candidates, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -589,19 +589,19 @@ internal sealed class UpdateEngineService
normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
candidates.Add(normalized);
if (!normalized.StartsWith($"{PdcObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
if (!normalized.StartsWith($"{PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
candidates.Add(Path.Combine(PdcObjectsDirectoryName, normalized));
candidates.Add(Path.Combine(PlondsObjectsDirectoryName, normalized));
}
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrWhiteSpace(fileName))
{
candidates.Add(Path.Combine(PdcObjectsDirectoryName, fileName));
candidates.Add(Path.Combine(PlondsObjectsDirectoryName, fileName));
}
}
private static bool TryGetExpectedSha512(PdcFileEntry file, out byte[] expected)
private static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected)
{
expected = [];
if (file.Sha512Bytes is { Length: > 0 })
@@ -636,7 +636,7 @@ internal sealed class UpdateEngineService
return TryParseHashBytes(file.Sha512Base64, out expected);
}
private static bool TryGetExpectedObjectSha512(PdcFileEntry file, out byte[] expected)
private static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected)
{
expected = [];
if (file.Hash is null)
@@ -724,9 +724,9 @@ internal sealed class UpdateEngineService
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
}
private static List<PdcFileEntry> CollectPdcFileEntries(PdcFileMap fileMap)
private static List<PlondsFileEntry> CollectPlondsFileEntries(PlondsFileMap fileMap)
{
var files = new List<PdcFileEntry>();
var files = new List<PlondsFileEntry>();
if (fileMap.Files is { Count: > 0 })
{
files.AddRange(fileMap.Files);
@@ -748,7 +748,7 @@ internal sealed class UpdateEngineService
return files;
}
private static void PopulatePdcManifestFromRawJson(string fileMapJson, PdcFileMap fileMap, ICollection<PdcFileEntry> files)
private static void PopulatePlondsManifestFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection<PlondsFileEntry> files)
{
if (string.IsNullOrWhiteSpace(fileMapJson))
{
@@ -794,7 +794,7 @@ internal sealed class UpdateEngineService
if (TryGetJsonPropertyIgnoreCase(root, "files", out var rootFilesNode))
{
ParsePdcFilesNode(rootFilesNode, null, files);
ParsePlondsFilesNode(rootFilesNode, null, files);
}
if (!TryGetJsonPropertyIgnoreCase(root, "components", out var componentsNode))
@@ -813,7 +813,7 @@ internal sealed class UpdateEngineService
if (TryGetJsonPropertyIgnoreCase(component.Value, "files", out var componentFilesNode))
{
ParsePdcFilesNode(componentFilesNode, component.Name, files);
ParsePlondsFilesNode(componentFilesNode, component.Name, files);
}
}
@@ -835,12 +835,12 @@ internal sealed class UpdateEngineService
var componentName = ReadJsonStringIgnoreCase(component, "name");
if (TryGetJsonPropertyIgnoreCase(component, "files", out var componentFilesNode))
{
ParsePdcFilesNode(componentFilesNode, componentName, files);
ParsePlondsFilesNode(componentFilesNode, componentName, files);
}
}
}
private static void ParsePdcFilesNode(JsonElement filesNode, string? componentName, ICollection<PdcFileEntry> files)
private static void ParsePlondsFilesNode(JsonElement filesNode, string? componentName, ICollection<PlondsFileEntry> files)
{
if (filesNode.ValueKind == JsonValueKind.Object)
{
@@ -851,7 +851,7 @@ internal sealed class UpdateEngineService
continue;
}
if (TryCreatePdcFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed))
if (TryCreatePlondsFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed))
{
files.Add(parsed);
}
@@ -873,16 +873,16 @@ internal sealed class UpdateEngineService
}
var fallbackPath = ReadJsonStringIgnoreCase(fileEntry, "path");
if (TryCreatePdcFileEntry(fallbackPath, componentName, fileEntry, out var parsed))
if (TryCreatePlondsFileEntry(fallbackPath, componentName, fileEntry, out var parsed))
{
files.Add(parsed);
}
}
}
private static bool TryCreatePdcFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PdcFileEntry entry)
private static bool TryCreatePlondsFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry)
{
entry = new PdcFileEntry();
entry = new PlondsFileEntry();
var path = ReadJsonStringIgnoreCase(node, "path");
if (string.IsNullOrWhiteSpace(path))
{
@@ -916,7 +916,30 @@ internal sealed class UpdateEngineService
metadata["component"] = componentName;
}
entry = new PdcFileEntry
if (TryGetJsonPropertyIgnoreCase(node, "metadata", out var metadataNode) &&
metadataNode.ValueKind == JsonValueKind.Object)
{
foreach (var property in metadataNode.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.Null ||
property.Value.ValueKind == JsonValueKind.Undefined)
{
continue;
}
var value = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString()
: property.Value.ToString();
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata[property.Name] = value;
}
}
entry = new PlondsFileEntry
{
Path = path,
Action = string.IsNullOrWhiteSpace(action) ? "replace" : action,
@@ -934,7 +957,7 @@ internal sealed class UpdateEngineService
if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text))
{
entry.Hash = new PdcHashDescriptor
entry.Hash = new PlondsHashDescriptor
{
Algorithm = "sha512",
Bytes = archiveSha512,
@@ -945,7 +968,7 @@ internal sealed class UpdateEngineService
}
else if (TryGetJsonPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object)
{
entry.Hash = new PdcHashDescriptor
entry.Hash = new PlondsHashDescriptor
{
Algorithm = ReadJsonStringIgnoreCase(hashNode, "algorithm"),
Value = ReadJsonStringIgnoreCase(hashNode, "value"),
@@ -956,6 +979,31 @@ internal sealed class UpdateEngineService
return true;
}
private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file)
{
if (OperatingSystem.IsWindows())
{
return;
}
if (!file.Metadata.TryGetValue("unixFileMode", out var rawMode) ||
string.IsNullOrWhiteSpace(rawMode))
{
return;
}
try
{
var normalized = rawMode.Trim();
var modeValue = Convert.ToInt32(normalized, 8);
File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue);
}
catch
{
// Best-effort only. A bad mode should not break the entire update.
}
}
private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{
if (node.ValueKind == JsonValueKind.Object)
@@ -1028,7 +1076,7 @@ internal sealed class UpdateEngineService
}
}
private static PdcUpdateMetadata? LoadPdcUpdateMetadata(string path)
private static PlondsUpdateMetadata? LoadPlondsUpdateMetadata(string path)
{
if (!File.Exists(path))
{
@@ -1043,7 +1091,7 @@ internal sealed class UpdateEngineService
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.PdcUpdateMetadata);
return JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata);
}
catch
{
@@ -1051,7 +1099,7 @@ internal sealed class UpdateEngineService
}
}
private static string? ResolvePdcSourceVersion(PdcFileMap fileMap, PdcUpdateMetadata? metadata)
private static string? ResolvePlondsSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.FromVersion,
@@ -1060,7 +1108,7 @@ internal sealed class UpdateEngineService
TryGetMetadataValue(fileMap.Metadata, "sourceVersion"));
}
private static string? ResolvePdcTargetVersion(PdcFileMap fileMap, PdcUpdateMetadata? metadata)
private static string? ResolvePlondsTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.ToVersion,
@@ -1107,7 +1155,7 @@ internal sealed class UpdateEngineService
}
/// <summary>
/// 全新安装场景:直接应用更新包作为首次部署
/// 闁稿繈鍔嶉弻濠勨偓鐟邦槼椤ュ﹪宕烽悜妯荤彲闁挎稒姘ㄥú鍧楀箳閵夈儳瀹夐柣顫妽濞插潡寮弶鍨樁濞达絾绮堢拹鐔革純閺嶎煈鍋ч梺顔哄妿鐠?
/// </summary>
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
SignedFileMap fileMap,
@@ -1123,7 +1171,7 @@ internal sealed class UpdateEngineService
var extractRoot = Path.Combine(_incomingRoot, "extracted");
try
{
// 保存快照(用于回滚,虽然首次安装回滚意义不大)
// Save a snapshot for diagnostics and future rollback consistency.
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
@@ -1136,7 +1184,7 @@ internal sealed class UpdateEngineService
};
SaveSnapshot(snapshotPath, snapshot);
// 清理并解压更新包
// 婵炴挸鎳愰幃濠囩嵁閹澏鎺楀储鐎n偅绾柡鍌涙緲鐎?
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
@@ -1144,17 +1192,17 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
// 创建目标部署目录
// 闁告帗绋戠紓鎾绘儎椤旂晫鍨奸梺顔哄妿鐠佹煡鎯勯鑲╃Э
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
// 应用所有文件(全新安装时,所有文件都是新增或替换)
// Apply all files from the extracted payload into the first deployment directory.
foreach (var file in fileMap.Files)
{
ApplyInitialFileEntry(file, targetDeployment, extractRoot);
}
// 验证文件哈希
// 濡ょ姴鐭侀惁澶愬棘閸ワ附顐介柛婵嗙墕缁?
foreach (var file in fileMap.Files)
{
if (!NeedsVerification(file))
@@ -1170,7 +1218,7 @@ internal sealed class UpdateEngineService
}
}
// 激活部署(创建 .current 标记,删除 .partial 标记)
// Mark the deployment as current and remove the partial marker.
var currentMarker = Path.Combine(targetDeployment, ".current");
File.WriteAllText(currentMarker, string.Empty);
if (File.Exists(partialMarker))
@@ -1178,8 +1226,7 @@ internal sealed class UpdateEngineService
File.Delete(partialMarker);
}
// 清理更新包
snapshot.Status = "applied";
// 婵炴挸鎳愰幃濠囧即鐎涙ɑ鐓€闁? snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
@@ -1195,7 +1242,7 @@ internal sealed class UpdateEngineService
}
catch (Exception ex)
{
// 清理失败的目标目录
// Clean up the failed target deployment before returning the error result.
try
{
if (Directory.Exists(targetDeployment))
@@ -1234,13 +1281,12 @@ internal sealed class UpdateEngineService
}
/// <summary>
/// 应用初始部署文件(全新安装场景,不需要源目录)
/// </summary>
/// 閹煎瓨姊婚弫銈夊礆濠靛棭娼楅梺顔哄妿鐠佹煡寮崶锔筋偨闁挎稑鐗嗛崣蹇涘棘閺夎法鏆旈悷浣告噹濠р偓闁哄拋鍨界槐婵囩▔瀹ュ浠橀悷鏇氱劍缁噣鎯勯鑲╃Э闁? /// </summary>
private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot)
{
var normalizedPath = NormalizeRelativePath(file.Path);
// 删除操作在全新安装时忽略
// 闁告帞濞€濞呭酣骞欏鍕▕闁革负鍔岄崣蹇涘棘閺夎法鏆旈悷浣告噺濡炲倽绠涢悾灞炬
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
@@ -1254,7 +1300,7 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(targetDir);
}
// 无论是 add 还是 replace都从压缩包复制
// 闁哄啰濮鹃鎴﹀及?add 閺夆晜蓱濡?replace闁挎稑鐭傞崗妯荤鎼粹€崇缂傚倵鏅涚€垫ɑ寰勫鍛厬
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
var extractedPath = Path.Combine(extractRoot, archiveRelative);
EnsurePathWithinRoot(extractedPath, extractRoot);
@@ -1419,9 +1465,9 @@ internal sealed class UpdateEngineService
Path.Combine(_incomingRoot, SignedFileMapName),
Path.Combine(_incomingRoot, SignatureFileName),
Path.Combine(_incomingRoot, ArchiveFileName),
Path.Combine(_incomingRoot, PdcFileMapName),
Path.Combine(_incomingRoot, PdcSignatureFileName),
Path.Combine(_incomingRoot, PdcUpdateMetadataName)
Path.Combine(_incomingRoot, PlondsFileMapName),
Path.Combine(_incomingRoot, PlondsSignatureFileName),
Path.Combine(_incomingRoot, PlondsUpdateMetadataName)
})
{
try
@@ -1438,7 +1484,7 @@ internal sealed class UpdateEngineService
foreach (var directory in new[]
{
Path.Combine(_incomingRoot, PdcObjectsDirectoryName)
Path.Combine(_incomingRoot, PlondsObjectsDirectoryName)
})
{
try

View File

@@ -1085,6 +1085,12 @@ public partial class App : Application
// 延迟报告 Ready 直到窗口实际打开并可见
// 使用 Opened 事件确保所有资源已加载完毕
mainWindow.Opened += OnMainWindowOpened;
// 手动显示窗口,因为在 ShutdownMode.OnExplicitShutdown 模式下框架不会自动调用 Show
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示

View File

@@ -35,16 +35,19 @@ public sealed record UpdateCheckResult(
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage,
bool ForceMode = false,
PdcUpdatePayload? PdcPayload = null);
PlondsUpdatePayload? PlondsPayload = null);
public sealed record PdcUpdatePayload(
public sealed record PlondsUpdatePayload(
string DistributionId,
string ChannelId,
string SubChannel,
string? FileMapJson,
string? FileMapSignature,
string? FileMapJsonUrl,
string? FileMapSignatureUrl);
string? FileMapSignatureUrl,
string? UpdateArchiveUrl = null,
string? UpdateArchiveSha256 = null,
long? UpdateArchiveSizeBytes = null);
public sealed record UpdateDownloadResult(
bool Success,
@@ -159,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,
@@ -167,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null);
ErrorMessage: null,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -232,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
: release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
var plondsPayload = TryResolvePlondsPayload(release);
return new UpdateCheckResult(
Success: true,
@@ -241,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: true);
ForceMode: true,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -652,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;
}
@@ -664,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)
@@ -719,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

@@ -1,580 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// Best-effort PDC client that maps PDC responses to the existing update result model.
/// This keeps launcher update contracts stable while allowing a gradual migration.
/// </summary>
public sealed class PdcReleaseUpdateService : IDisposable
{
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public PdcReleaseUpdateService(HttpClient? httpClient = null)
{
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
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);
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
var endpoint = ResolveEndpoint();
if (string.IsNullOrWhiteSpace(endpoint))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC endpoint is not configured.",
ForceMode: isForce);
}
try
{
var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata");
var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(metadata, includePrerelease);
if (string.IsNullOrWhiteSpace(channelId))
{
channelId = includePrerelease ? "preview" : "stable";
}
var latestUrl = BuildUri(
endpoint,
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution version is invalid.",
ForceMode: isForce);
}
var distributionId = ReadString(latestNode, "distributionId");
if (string.IsNullOrWhiteSpace(distributionId))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution id is missing.",
ForceMode: isForce);
}
var hasUpdate = latestVersion > normalizedCurrentVersion;
if (!isForce && !hasUpdate)
{
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: null,
ForceMode: false);
}
var subChannel = ResolveSubChannel();
var distributionUrl = BuildUri(
endpoint,
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
var assets = ResolveAssets(distributionNode);
var pdcPayload = ResolvePdcPayload(distributionNode, distributionId, channelId, subChannel);
if (assets.Count == 0 && !HasPdcPayload(pdcPayload))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}",
Name: $"PDC Distribution {latestVersionText}",
IsPrerelease: includePrerelease,
IsDraft: false,
PublishedAt: DateTimeOffset.UtcNow,
Assets: assets);
var preferredAsset = SelectPreferredInstallerAsset(assets);
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: isForce,
PdcPayload: pdcPayload);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: $"PDC request failed: {ex.Message}",
ForceMode: isForce);
}
}
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var token = ResolveToken();
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
}
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("content", out var content))
{
return content.Clone();
}
return root.Clone();
}
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
{
var assets = new List<GitHubReleaseAsset>();
if (distributionNode.ValueKind != JsonValueKind.Object)
{
return assets;
}
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
assetsNode.ValueKind == JsonValueKind.Array)
{
foreach (var assetNode in assetsNode.EnumerateArray())
{
if (assetNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = ReadString(assetNode, "name");
var url = ReadString(assetNode, "url") ??
ReadString(assetNode, "downloadUrl") ??
ReadString(assetNode, "browserDownloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
}
var size = ReadInt64(assetNode, "size") ?? 0L;
var sha256 = ReadString(assetNode, "sha256");
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
}
}
if (assets.Count > 0)
{
return assets;
}
// Field-level fallback for service-side URL projection.
var manifestUrl = ReadString(distributionNode, "manifestUrl")
?? ReadString(distributionNode, "fileMapUrl");
var signatureUrl = ReadString(distributionNode, "signatureUrl")
?? ReadString(distributionNode, "fileMapSignatureUrl");
var archiveUrl = ReadString(distributionNode, "archiveUrl")
?? ReadString(distributionNode, "updateArchiveUrl")
?? ReadString(distributionNode, "payloadUrl");
if (!string.IsNullOrWhiteSpace(manifestUrl))
{
assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(signatureUrl))
{
assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(archiveUrl))
{
assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null));
}
return assets;
}
private static PdcUpdatePayload ResolvePdcPayload(
JsonElement distributionNode,
string distributionId,
string channelId,
string subChannel)
{
var fileMapJson = ReadString(distributionNode, "fileMapJson");
var fileMapSignature = ReadString(distributionNode, "fileMapSignature");
var fileMapJsonUrl = ReadString(distributionNode, "fileMapJsonUrl")
?? ReadString(distributionNode, "fileMapUrl")
?? ReadString(distributionNode, "manifestUrl");
var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl")
?? ReadString(distributionNode, "signatureUrl");
return new PdcUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: subChannel,
FileMapJson: fileMapJson,
FileMapSignature: fileMapSignature,
FileMapJsonUrl: fileMapJsonUrl,
FileMapSignatureUrl: fileMapSignatureUrl);
}
private static bool HasPdcPayload(PdcUpdatePayload payload)
{
return !string.IsNullOrWhiteSpace(payload.FileMapJson)
|| !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl);
}
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0)
{
return null;
}
if (OperatingSystem.IsWindows())
{
var archToken = RuntimeInformation.OSArchitecture switch
{
Architecture.Arm64 => "arm64",
Architecture.X86 => "x86",
_ => "x64"
};
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
if (OperatingSystem.IsLinux())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".deb", ".rpm", "x64")))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
if (OperatingSystem.IsMacOS())
{
var archToken = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64";
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".dmg", ".pkg", archToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return null;
}
private static int ScoreInstallerAsset(string name, string ext1, string ext2, string archToken)
{
if (string.IsNullOrWhiteSpace(name))
{
return 0;
}
var score = 0;
if (name.EndsWith(ext1, StringComparison.OrdinalIgnoreCase))
{
score += 200;
}
else if (name.EndsWith(ext2, StringComparison.OrdinalIgnoreCase))
{
score += 160;
}
else
{
return 0;
}
if (name.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
name.Contains("installer", StringComparison.OrdinalIgnoreCase))
{
score += 50;
}
if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (name.Contains("portable", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
{
if (metadataNode.ValueKind != JsonValueKind.Object ||
!metadataNode.TryGetProperty("channels", out var channelsNode))
{
return includePrerelease ? "preview" : "stable";
}
var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty;
if (channelsNode.ValueKind != JsonValueKind.Object)
{
return defaultChannelId;
}
string? matchedPreview = null;
string? matchedStable = null;
foreach (var channel in channelsNode.EnumerateObject())
{
var name = ReadString(channel.Value, "name") ?? channel.Name;
if (string.IsNullOrWhiteSpace(matchedPreview) &&
(name.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
name.Contains("beta", StringComparison.OrdinalIgnoreCase) ||
name.Contains("dev", StringComparison.OrdinalIgnoreCase)))
{
matchedPreview = channel.Name;
}
if (string.IsNullOrWhiteSpace(matchedStable) &&
(name.Contains("stable", StringComparison.OrdinalIgnoreCase) ||
name.Contains("release", StringComparison.OrdinalIgnoreCase)))
{
matchedStable = channel.Name;
}
}
if (includePrerelease)
{
return matchedPreview ?? defaultChannelId ?? "preview";
}
return matchedStable ?? defaultChannelId ?? "stable";
}
private static string ResolveSubChannel()
{
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL")
?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL");
if (!string.IsNullOrWhiteSpace(configured))
{
return configured.Trim();
}
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}_release_folderClassic";
}
private static string? ResolveEndpoint()
{
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
}
private static string? ResolveToken()
{
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
}
private static string BuildUri(string endpoint, string relativePath)
{
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
}
private static string? ReadString(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String
? value.GetString()
: value.ToString();
}
private static long? ReadInt64(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
{
return null;
}
if (value.TryGetInt64(out var number))
{
return number;
}
var text = value.ToString();
return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim().TrimStart('v', 'V');
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = NormalizeVersion(parsed);
return true;
}
private static Version NormalizeVersion(Version version)
{
var major = Math.Max(0, version.Major);
var minor = Math.Max(0, version.Minor);
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
return revision > 0
? new Version(major, minor, build, revision)
: new Version(major, minor, build);
}
private static string FormatVersionText(Version version)
{
return version.Revision > 0
? version.ToString(4)
: version.ToString(3);
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
}

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,7 +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<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, 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

@@ -752,7 +752,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
{
@@ -842,16 +842,16 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public async Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
Version currentVersion,
bool includePrerelease,
bool isForce = false,
CancellationToken cancellationToken = default)
{
var result = isForce
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return result.Success ? result.PdcPayload : null;
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return result.Success ? result.PlondsPayload : null;
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
@@ -891,7 +891,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
_pdcReleaseUpdateService.Dispose();
_plondsReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
@@ -901,20 +901,39 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
var pdcResult = isForce
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (pdcResult.Success)
if (plondsResult.Success)
{
return pdcResult;
return plondsResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}");
$"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
@@ -1271,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>()?
@@ -1318,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

@@ -12,9 +12,11 @@ public static class UpdateSettingsValues
public const string ModeSilentOnExit = "silent_on_exit";
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePdc = "stcn";
public const string DownloadSourceStcn = DownloadSourcePdc;
public const string LegacyDownloadSourcePdc = "pdc";
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";
@@ -55,14 +57,14 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
if (string.Equals(value, LegacyDownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceStcn;
}
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePdc;
return DownloadSourcePlonds;
}
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
@@ -75,7 +77,7 @@ public static class UpdateSettingsValues
return DownloadSourceGitHub;
}
// Default to STCN(PDC/S3). Runtime will fallback to GitHub if STCN is unavailable.
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
return DownloadSourceStcn;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1965,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.")
@@ -2050,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;
@@ -2233,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.");

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

@@ -18,6 +18,15 @@ components:
variables:
number: 0
fileRepoRoot: "__FILE_REPO_ROOT__"
archiveRoot: "__ARCHIVE_ROOT__"
archiveRoot: "__ARCHIVE_ROOT__/$(primaryVersion)/$(version)/"
bucketKeyRoot: "lanmountain/update/repo/"
archiveBucketKeyRoot: "lanmountain/update/installers/"
archiveBucketKeyRoot: "lanmountain/update/archive/$(primaryVersion)/$(version)/"
appChangeLogPath: "$(thisFileDir)/../CHANGELOG.md"
appChangeLogTemplate: |
$(changeLog)
---
## Checksums And Downloads
$(hashes)

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."
}

View File

@@ -36,10 +36,6 @@ if ([string]::IsNullOrWhiteSpace($releaseTag)) {
$releaseTag = $env:PDCC_VERSION
}
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
$releaseTag = $env:PDCC_version
}
$tempDir = Join-Path $env:RUNNER_TEMP "pdcc-install"
if (Test-Path -LiteralPath $tempDir) {
Remove-Item -LiteralPath $tempDir -Recurse -Force

1044
scripts/Publish-Plonds.ps1 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
param(
param(
[Parameter(Mandatory = $true)]
[string]$FilesJsonPath,
@@ -11,46 +11,16 @@ param(
$ErrorActionPreference = "Stop"
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
}
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
throw "Manifest file not found: $FilesJsonPath"
}
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
throw "Private key file not found: $PrivateKeyPath"
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = "$FilesJsonPath.sig"
}
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
throw "Private key PEM is empty: $PrivateKeyPath"
$toolProject = Join-Path $PSScriptRoot "..\PenguinLogisticsOnlineNetworkDistributionSystem\src\Plonds.Tool\Plonds.Tool.csproj"
if (-not (Test-Path -LiteralPath $toolProject)) {
throw "PLONDS tool project not found: $toolProject"
}
$rsa = [System.Security.Cryptography.RSA]::Create()
try {
$rsa.ImportFromPem($privateKeyPem)
$signatureBytes = $rsa.SignData(
$manifestBytes,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
)
& dotnet run --project $toolProject -- sign --manifest $FilesJsonPath --private-key $PrivateKeyPath --output $OutputPath
if ($LASTEXITCODE -ne 0) {
throw "PLONDS sign command failed."
}
finally {
$rsa.Dispose()
}
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
Write-Host "Signed manifest file."
Write-Host "Manifest: $FilesJsonPath"
Write-Host "Signature: $OutputPath"

206
scripts/pdc-mock-server.py Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
import argparse
import json
import re
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
def _utc_now_text() -> str:
return datetime.now(timezone.utc).isoformat()
class PdcMockHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
token = ""
data_dir = Path(".")
def _write_json(self, status_code: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
self.wfile.flush()
self.close_connection = True
def handle_expect_100(self) -> bool:
self.send_response_only(100)
self.end_headers()
return True
def _read_chunked_body(self) -> bytes:
chunks = bytearray()
while True:
size_line = self.rfile.readline()
if not size_line:
break
size_line = size_line.strip()
if not size_line:
continue
size_text = size_line.split(b";", 1)[0]
chunk_size = int(size_text, 16)
if chunk_size == 0:
# Consume optional trailer headers until the terminating blank line.
while True:
trailer = self.rfile.readline()
if trailer in (b"", b"\r\n", b"\n"):
break
break
remaining = chunk_size
while remaining > 0:
part = self.rfile.read(remaining)
if not part:
raise ConnectionError("unexpected end of stream while reading chunked request body")
chunks.extend(part)
remaining -= len(part)
chunk_terminator = self.rfile.read(2)
if chunk_terminator == b"\r\n":
continue
if chunk_terminator[:1] != b"\n":
raise ValueError("invalid chunk terminator")
return bytes(chunks)
def _read_request_body(self) -> bytes:
transfer_encoding = (self.headers.get("Transfer-Encoding") or "").lower()
if "chunked" in transfer_encoding:
return self._read_chunked_body()
length = int(self.headers.get("Content-Length", "0"))
if length <= 0:
return b""
return self.rfile.read(length)
def _read_json_body(self) -> tuple[dict, bytes]:
raw = self._read_request_body()
if not raw:
return {}, raw
try:
return json.loads(raw.decode("utf-8")), raw
except Exception:
return {}, raw
def _save_payload(self, name: str, payload: dict, raw_body: bytes) -> None:
out = self.data_dir / f"{name}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(
json.dumps(
{
"savedAtUtc": _utc_now_text(),
"path": self.path,
"method": self.command,
"headers": {key: value for key, value in self.headers.items()},
"rawBodyLength": len(raw_body),
"rawBodyPreview": raw_body[:4096].decode("utf-8", errors="replace"),
"payload": payload,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def _check_token(self) -> bool:
expected = (self.token or "").strip()
if not expected:
return True
provided = (self.headers.get("X-PDC-Token") or "").strip()
return provided == expected
def do_GET(self) -> None:
if self.path == "/healthz":
self._write_json(200, {"ok": True, "timeUtc": _utc_now_text()})
return
self._write_json(404, {"error": "not_found", "path": self.path})
def do_POST(self) -> None:
print(
f"[pdc-mock] {self.command} {self.path} "
f"content-length={self.headers.get('Content-Length', '')} "
f"transfer-encoding={self.headers.get('Transfer-Encoding', '')} "
f"expect={self.headers.get('Expect', '')}"
)
if not self._check_token():
self._write_json(401, {"error": "unauthorized"})
return
payload, raw_body = self._read_json_body()
if self.path == "/api/v1/fileMaps/diff":
items = payload.get("items") if isinstance(payload, dict) else {}
keys = sorted(items.keys()) if isinstance(items, dict) else []
self._save_payload("filemaps-diff-request", payload, raw_body)
# CI fallback mode: return empty diff to avoid long object uploads
# against a local mock endpoint. Real PDC endpoint will return
# actual missing object hashes.
result = {
"success": True,
"code": 0,
"message": "ok",
"content": [],
"Content": [],
"requestedCount": len(keys),
}
self._write_json(200, result)
return
if self.path == "/api/v1/fileMaps/upload":
self._save_payload("filemaps-upload-request", payload, raw_body)
result = {
"success": True,
"code": 0,
"message": "ok",
"content": True,
"Content": True,
}
self._write_json(200, result)
return
m = re.match(r"^/api/v1/distribution/([^/]+)/([^/]+)$", self.path)
if m:
primary_version = m.group(1)
version = m.group(2)
self._save_payload("distribution-request", payload, raw_body)
result = {
"success": True,
"code": 0,
"message": "ok",
}
self._write_json(200, result)
return
self._write_json(404, {"error": "not_found", "path": self.path})
def log_message(self, fmt: str, *args) -> None:
print(f"[pdc-mock] {self.address_string()} - {fmt % args}")
def main() -> None:
parser = argparse.ArgumentParser(description="PDC mock server for CI fallback")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=18765)
parser.add_argument("--token", default="")
parser.add_argument("--data-dir", required=True)
args = parser.parse_args()
PdcMockHandler.token = args.token
PdcMockHandler.data_dir = Path(args.data_dir)
PdcMockHandler.data_dir.mkdir(parents=True, exist_ok=True)
server = ThreadingHTTPServer((args.host, args.port), PdcMockHandler)
print(f"[pdc-mock] listening on http://{args.host}:{args.port}")
server.serve_forever()
if __name__ == "__main__":
main()