Compare commits

...

4 Commits

Author SHA1 Message Date
lincube
631dc7795a Normalize release artifacts before publishing 2026-04-21 21:17:52 +08:00
lincube
001a42a97f Fix Windows installer script path in release workflow 2026-04-21 20:18:12 +08:00
lincube
8a75bc818a Rebuild release pipeline around PLONDS and DDSS 2026-04-21 19:26:59 +08:00
lincube
8568fdf16b ci.plonds 2026-04-21 16:12:47 +08:00
29 changed files with 2918 additions and 1310 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

@@ -1,4 +1,4 @@
name: Release name: Release
on: on:
push: push:
@@ -30,14 +30,22 @@ jobs:
informational_version: ${{ steps.version.outputs.informational_version }} informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }} tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }} checkout_ref: ${{ steps.version.outputs.checkout_ref }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
release_channel: ${{ steps.version.outputs.release_channel }}
steps: steps:
- name: Checkout repository metadata
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info - name: Get release info
id: version id: version
run: | run: |
if [[ "${{ github.event_name }}" == "push" ]]; then if [[ "${{ github.event_name }}" == "push" ]]; then
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
CHECKOUT_REF="${GITHUB_REF}" CHECKOUT_REF="${GITHUB_REF}"
IS_PRERELEASE="false"
else else
RAW_TAG="${{ github.event.inputs.tag }}" RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == refs/tags/* ]]; then if [[ "${RAW_TAG}" == refs/tags/* ]]; then
@@ -47,19 +55,40 @@ jobs:
else else
TAG="v${RAW_TAG}" TAG="v${RAW_TAG}"
fi 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 fi
VERSION="${TAG#v}" VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}" IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
VERSION_PARTS+=("0") VERSION_PARTS+=("0")
done done
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}" ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT if [[ "${IS_PRERELEASE}" == "true" ]]; then
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT RELEASE_CHANNEL="preview"
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT else
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT 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: build-windows:
needs: prepare needs: prepare
@@ -107,9 +136,6 @@ jobs:
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$launcherPublishDir = "publish/launcher-win-$arch" $launcherPublishDir = "publish/launcher-win-$arch"
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT publish
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj ` dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release ` -c Release `
-o ./$launcherPublishDir ` -o ./$launcherPublishDir `
@@ -120,27 +146,16 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true ` -p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true ` -p:EnableCompressionInSingleFile=true `
-p:DebugType=none ` -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) { if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed" Write-Error "Launcher AOT publish failed"
exit 1 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 shell: pwsh
- name: Publish Main App - name: Publish Main App
@@ -178,9 +193,6 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
} }
Write-Host "Published to: $publishDir"
Write-Host "Self-contained: $selfContained"
shell: pwsh shell: pwsh
- name: Restructure for Launcher - name: Restructure for Launcher
@@ -191,30 +203,18 @@ jobs:
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$launcherPublishDir = "publish/launcher-win-$arch" $launcherPublishDir = "publish/launcher-win-$arch"
$appDir = "app-$version" $appDir = "app-$version"
Write-Host "Restructuring for Launcher mode..."
Write-Host "Version: $version"
Write-Host "Publish dir: $publishDir"
$newStructure = "publish-launcher/windows-$arch" $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 $appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force Move-Item -Path $publishDir -Destination $appPath -Force
$launcherSource = $launcherPublishDir if (Test-Path $launcherPublishDir) {
if (Test-Path $launcherSource) { Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
Write-Host "Copying Launcher to root..."
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
} else {
Write-Warning "Launcher publish dir not found: $launcherSource"
} }
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null 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 $publishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -Path $newStructure -Destination $publishDir -Force Move-Item -Path $newStructure -Destination $publishDir -Force
@@ -230,60 +230,31 @@ jobs:
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$suffix = "${{ matrix.suffix }}" $suffix = "${{ matrix.suffix }}"
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" } $selfContained = "${{ matrix.self_contained }}" -eq "true"
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss" $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$outputDir = "build-installer" $outputDir = "build-installer"
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
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
}
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null 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 = @( $candidatePaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe", (Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
"C:\Program Files\Inno Setup 6\ISCC.exe", "$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\bin\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe" "$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
) ) | Where-Object { $_ -and (Test-Path $_) }
$isccPath = $candidatePaths | Select-Object -First 1
if (-not $isccPath) { 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." Write-Error "Inno Setup compiler not found."
exit 1 exit 1
} }
Write-Host "Found Inno Setup at: $isccPath" if (-not (Test-Path $installerScript)) {
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
Write-Host "Building installer for Windows $arch with version $version..." exit 1
}
$publishDir = (Resolve-Path $publishDir).Path $publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path $outputDir = (Resolve-Path $outputDir).Path
@@ -299,8 +270,6 @@ jobs:
$installerScript $installerScript
) )
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
& $isccPath @compileArgs & $isccPath @compileArgs
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE" Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
@@ -312,25 +281,53 @@ jobs:
Write-Error "Failed to create installer" Write-Error "Failed to create installer"
exit 1 exit 1
} }
Write-Host "Successfully created: $($installerFile.Name)"
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh shell: pwsh
- name: Upload App Payload - name: Package Payload Zip
uses: actions/upload-artifact@v4 run: |
with: $version = "${{ needs.prepare.outputs.version }}"
name: app-payload-windows-${{ matrix.arch }} $arch = "${{ matrix.arch }}"
path: | $payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
publish/windows-${{ matrix.arch }}/** if (-not (Test-Path $payloadRoot)) {
if-no-files-found: error Write-Error "Payload root not found: $payloadRoot"
retention-days: 30 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 uses: actions/upload-artifact@v4
with: with:
name: installer-windows-${{ matrix.arch }} name: release-windows-${{ matrix.arch }}
path: build-installer/*.exe path: |
release-assets/files-windows-${{ matrix.arch }}.zip
build-installer/*.exe
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -355,13 +352,10 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \ libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \ libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \ 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 libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2 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 sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET - name: Setup .NET
@@ -383,8 +377,6 @@ jobs:
- name: Publish Launcher (AOT) - name: Publish Launcher (AOT)
run: | run: |
echo "Publishing Launcher with AOT for Linux x64..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \ -c Release \
-o ./publish/launcher-linux-x64 \ -o ./publish/launcher-linux-x64 \
@@ -395,15 +387,11 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \ -p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \ -p:EnableCompressionInSingleFile=true \
-p:DebugType=none \ -p:DebugType=none \
-p:DebugSymbols=false -p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
if [ $? -ne 0 ]; then -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
echo "Launcher AOT publish failed" -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
exit 1 -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
fi
echo "Launcher published to: ./publish/launcher-linux-x64"
ls -lh ./publish/launcher-linux-x64/
- name: Publish Main App - name: Publish Main App
run: | run: |
@@ -430,25 +418,15 @@ jobs:
appDir="app-$version" appDir="app-$version"
launcherDir="publish/launcher-linux-x64" launcherDir="publish/launcher-linux-x64"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "$publishDir" mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir" mv "publish/linux-x64-app" "$publishDir/$appDir"
if [ -d "$launcherDir" ]; then if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "$publishDir/" cp -r "$launcherDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi fi
touch "$publishDir/$appDir/.current" touch "$publishDir/$appDir/.current"
echo "New directory structure:"
find "$publishDir" -maxdepth 2 | head -50
rm -rf "$launcherDir" rm -rf "$launcherDir"
- name: Package as DEB - name: Package as DEB
@@ -461,12 +439,6 @@ jobs:
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop" desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png" 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/DEBIAN"
mkdir -p "build-deb/usr/local/bin" mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications" mkdir -p "build-deb/usr/share/applications"
@@ -475,20 +447,6 @@ jobs:
cp -r "$source"/* "build-deb/usr/local/bin/" 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 \ sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \ -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \ -e "s|@@ICON@@|lanmountaindesktop|g" \
@@ -512,9 +470,9 @@ jobs:
printf '%s\n' "Package: $package_name" printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version" printf '%s\n' "Version: $package_version"
printf '%s\n' "Architecture: $arch" printf '%s\n' "Architecture: $arch"
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>" printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
printf '%s\n' "Description: LanMountain Desktop Application" printf '%s\n' 'Description: LanMountain Desktop Application'
printf '%s\n' " A desktop application for LanMountain." printf '%s\n' ' A desktop application for LanMountain.'
} > "build-deb/DEBIAN/control" } > "build-deb/DEBIAN/control"
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/* 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 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst" chmod 755 "build-deb/DEBIAN/postinst"
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
ls -lh "${package_name}_${package_version}_${arch}.deb" - name: Package Payload Zip
else run: |
echo "Error: Failed to build DEB package" 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 exit 1
fi fi
- name: Upload App Payload rm -rf "$stage_dir"
uses: actions/upload-artifact@v4 mkdir -p "$stage_dir" "$release_dir"
with: rsync -a \
name: app-payload-linux-x64 --exclude '.current' \
path: | --exclude '.partial' \
publish/linux-x64/** --exclude '.destroy' \
if-no-files-found: error "$payload_root/" "$stage_dir/"
retention-days: 30
- name: Upload Installer (
cd "$stage_dir"
zip -qr "$release_dir/files-linux-x64.zip" .
)
- name: Upload Release Assets
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: installer-linux-x64 name: release-linux-x64
path: "*.deb" path: |
release-assets/files-linux-x64.zip
*.deb
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
build-macos: build-macos:
needs: prepare needs: prepare
runs-on: macos-latest runs-on: macos-latest
continue-on-error: true
strategy: strategy:
fail-fast: false
matrix: matrix:
arch: [x64, arm64] arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }} name: Build_macOS_${{ matrix.arch }}
@@ -586,8 +558,6 @@ jobs:
- name: Publish Launcher (AOT) - name: Publish Launcher (AOT)
run: | run: |
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \ -c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \ -o ./publish/launcher-macos-${{ matrix.arch }} \
@@ -598,15 +568,11 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \ -p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \ -p:EnableCompressionInSingleFile=true \
-p:DebugType=none \ -p:DebugType=none \
-p:DebugSymbols=false -p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
if [ $? -ne 0 ]; then -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
echo "Launcher AOT publish failed" -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
exit 1 -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
fi
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
- name: Publish Main App - name: Publish Main App
run: | run: |
@@ -626,7 +592,22 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_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 - name: Restructure and Package as DMG
continue-on-error: true
run: | run: |
version="${{ needs.prepare.outputs.version }}" version="${{ needs.prepare.outputs.version }}"
arch="${{ matrix.arch }}" arch="${{ matrix.arch }}"
@@ -635,41 +616,19 @@ jobs:
launcherDir="publish/launcher-macos-$arch" launcherDir="publish/launcher-macos-$arch"
appSourceDir="publish/macos-$arch-app" appSourceDir="publish/macos-$arch-app"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "${app_name}.app/Contents/MacOS" mkdir -p "${app_name}.app/Contents/MacOS"
appDir="app-$version" appDir="app-$version"
mkdir -p "${app_name}.app/Contents/MacOS/$appDir" mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
if [ -d "$appSourceDir" ]; then cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
else
echo "Error: Main app source directory not found: $appSourceDir"
exit 1
fi
if [ -d "$launcherDir" ]; then if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/" cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi fi
touch "${app_name}.app/Contents/MacOS/$appDir/.current" touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources" 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' '<?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">' printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
@@ -693,233 +652,96 @@ jobs:
mkdir -p dmg-temp mkdir -p dmg-temp
cp -r "${app_name}.app" 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 - name: Upload Release Assets
echo "Successfully created: ${package_name}.dmg" if: always()
ls -lh "${package_name}.dmg"
else
echo "Error: Failed to create DMG"
exit 1
fi
rm -rf dmg-temp "${app_name}.app"
- name: Upload
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: installer-macos-${{ matrix.arch }} name: release-macos-${{ matrix.arch }}
path: "*.dmg" path: |
if-no-files-found: error release-assets/files-macos-${{ matrix.arch }}.zip
*.dmg
if-no-files-found: ignore
retention-days: 30 retention-days: 30
publish-plonds:
needs: [ prepare, build-windows, build-linux ]
runs-on: ubuntu-latest
permissions:
contents: read
env:
VERSION: ${{ needs.prepare.outputs.version }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
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 }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Download app payload artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/app-payload
pattern: app-payload-*
- name: Download installer artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/installers
pattern: installer-*
- name: Prepare signing key
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
function Test-PemKey {
param([string]$PemText)
if ([string]::IsNullOrWhiteSpace($PemText)) {
return $false
}
$rsa = [System.Security.Cryptography.RSA]::Create()
try {
$rsa.ImportFromPem($PemText)
return $true
}
catch {
return $false
}
finally {
$rsa.Dispose()
}
}
$candidates = @(
$env:PLONDS_SIGNING_KEY,
$env:UPDATE_PRIVATE_KEY_PEM,
$env:PDC_SIGNING_KEY
)
$key = $null
foreach ($candidate in $candidates) {
if (Test-PemKey $candidate) {
$key = $candidate
break
}
}
if ([string]::IsNullOrWhiteSpace($key)) {
throw "Missing a valid PEM signing key in PLONDS_SIGNING_KEY, UPDATE_PRIVATE_KEY_PEM, or PDC_SIGNING_KEY."
}
$keyPath = Join-Path $PWD "update-private-key.pem"
[System.IO.File]::WriteAllText($keyPath, $key, [System.Text.Encoding]::ASCII)
Add-Content -Path $env:GITHUB_ENV -Value "UPDATE_PRIVATE_KEY_PATH=$keyPath"
- name: Probe S3 access
if: ${{ env.S3_ENDPOINT != '' && env.S3_BUCKET != '' && env.S3_ACCESS_KEY != '' && env.S3_SECRET_KEY != '' }}
shell: bash
run: |
set -euo pipefail
aws --version
aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 ls "s3://$S3_BUCKET" >/dev/null
echo "S3 access probe succeeded for $S3_BUCKET"
- name: Build PLONDS assets
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
./scripts/Publish-Plonds.ps1 `
-Version $env:VERSION `
-AppArtifactsRoot (Join-Path $PWD "artifacts/app-payload") `
-InstallerArtifactsRoot (Join-Path $PWD "artifacts/installers") `
-OutputDir (Join-Path $PWD "plonds-output") `
-PrivateKeyPath $env:UPDATE_PRIVATE_KEY_PATH `
-Channel "stable" `
-S3Endpoint $env:S3_ENDPOINT `
-S3Bucket $env:S3_BUCKET `
-S3Region $env:S3_REGION
- name: Upload PLONDS assets
uses: actions/upload-artifact@v4
with:
name: plonds-assets
path: |
plonds-output/release-assets/**
plonds-output/published/**
if-no-files-found: error
retention-days: 90
github-release: github-release:
needs: [ prepare, build-windows, build-linux, build-macos, publish-plonds ] needs: [prepare, build-windows, build-linux]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
steps: steps:
- name: Download installer artifacts - name: Download release artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
path: artifacts/installers path: release-files
pattern: installer-* pattern: release-*
merge-multiple: true
- name: Download PLONDS artifacts - name: Normalize release files
uses: actions/download-artifact@v4
with:
path: artifacts/plonds
pattern: plonds-assets
- name: List artifacts structure
run: | run: |
echo "Artifact directory structure:" mkdir -p release-bundle
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'
- name: Flatten artifacts for release mapfile -t downloaded_files < <(find release-files -type f)
run: | if [ "${#downloaded_files[@]}" -eq 0 ]; then
echo "Organizing artifacts..." echo "No downloaded release artifacts were found."
mkdir -p release-files
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
find artifacts/plonds -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" -o -name "plonds-*.json" -o -name "plonds-*.json.sig" \) -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"
exit 1 exit 1
fi 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 uses: ncipollo/release-action@v1
with: with:
tag: ${{ needs.prepare.outputs.tag }} tag: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }} name: ${{ needs.prepare.outputs.tag }}
commit: ${{ github.sha }}
allowUpdates: true allowUpdates: true
draft: false draft: false
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }} prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
artifacts: "release-files/**" artifacts: 'release-bundle/*'
body: | body: |
## Release ${{ needs.prepare.outputs.version }} ## Release ${{ needs.prepare.outputs.version }}
### Windows ### Installers
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime) - `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime) - `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. ### Payload Archives
- `files-windows-x64.zip`
Installation: Double-click the .exe file and follow the wizard. - `files-windows-x86.zip`
- `files-linux-x64.zip`
### Incremental Update Assets`n - **plonds-filemap-windows-x64.json / plonds-filemap-windows-x64.json.sig**`n - **plonds-filemap-windows-x86.json / plonds-filemap-windows-x86.json.sig**`n - **plonds-filemap-linux-x64.json / plonds-filemap-linux-x64.json.sig**`n`n ### Legacy Fallback 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: Host will prefer staged PLONDS payloads and keep the Launcher responsible for apply + rollback. Legacy signed file-map assets remain attached as a fallback path.
### Linux
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
### macOS ### macOS
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor - macOS assets are best-effort and will not block the release.
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
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 }} token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -465,6 +465,7 @@ internal sealed class UpdateEngineService
} }
File.Copy(sourcePath, targetPath, overwrite: true); File.Copy(sourcePath, targetPath, overwrite: true);
ApplyUnixFileModeIfPresent(targetPath, file);
return; return;
} }
@@ -472,6 +473,7 @@ internal sealed class UpdateEngineService
var objectBytes = File.ReadAllBytes(objectPath); var objectBytes = File.ReadAllBytes(objectPath);
var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes; var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes;
File.WriteAllBytes(targetPath, restoredBytes); File.WriteAllBytes(targetPath, restoredBytes);
ApplyUnixFileModeIfPresent(targetPath, file);
} }
private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment) private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment)
@@ -914,6 +916,29 @@ internal sealed class UpdateEngineService
metadata["component"] = componentName; metadata["component"] = componentName;
} }
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 entry = new PlondsFileEntry
{ {
Path = path, Path = path,
@@ -954,6 +979,31 @@ internal sealed class UpdateEngineService
return true; 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) private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{ {
if (node.ValueKind == JsonValueKind.Object) if (node.ValueKind == JsonValueKind.Object)

View File

@@ -44,7 +44,10 @@ public sealed record PlondsUpdatePayload(
string? FileMapJson, string? FileMapJson,
string? FileMapSignature, string? FileMapSignature,
string? FileMapJsonUrl, string? FileMapJsonUrl,
string? FileMapSignatureUrl); string? FileMapSignatureUrl,
string? UpdateArchiveUrl = null,
string? UpdateArchiveSha256 = null,
long? UpdateArchiveSizeBytes = null);
public sealed record UpdateDownloadResult( public sealed record UpdateDownloadResult(
bool Success, bool Success,
@@ -159,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var preferredAsset = isUpdateAvailable var preferredAsset = isUpdateAvailable
? SelectPreferredInstallerAsset(release.Assets) ? SelectPreferredInstallerAsset(release.Assets)
: null; : null;
var plondsPayload = isUpdateAvailable
? TryResolvePlondsPayload(release)
: null;
return new UpdateCheckResult( return new UpdateCheckResult(
Success: true, Success: true,
@@ -167,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText, LatestVersionText: latestVersionText,
Release: release, Release: release,
PreferredAsset: preferredAsset, PreferredAsset: preferredAsset,
ErrorMessage: null); ErrorMessage: null,
PlondsPayload: plondsPayload);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -232,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
: release.TagName; : release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets); var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
var plondsPayload = TryResolvePlondsPayload(release);
return new UpdateCheckResult( return new UpdateCheckResult(
Success: true, Success: true,
@@ -241,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
Release: release, Release: release,
PreferredAsset: preferredAsset, PreferredAsset: preferredAsset,
ErrorMessage: null, ErrorMessage: null,
ForceMode: true); ForceMode: true,
PlondsPayload: plondsPayload);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -652,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets) 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; return null;
} }
@@ -664,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
_ => "x64" _ => "x64"
}; };
var ranked = assets if (OperatingSystem.IsWindows())
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken))) {
.OrderByDescending(x => x.Score) return assets
.ToList(); .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) private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
@@ -719,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return score; 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) private static bool TryParseVersion(string? value, out Version? version)
{ {
version = null; version = null;

View File

@@ -1,50 +1,17 @@
using System; 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;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
/// <summary> /// <summary>
/// Thin PLONDS client used by the host app. /// Release-backed PLONDS checker.
/// The host keeps responsibility for checking and downloading updates; Launcher only applies staged payloads. /// 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> /// </summary>
public sealed class PlondsReleaseUpdateService : IDisposable public sealed class PlondsReleaseUpdateService : IDisposable
{ {
private const string DefaultApiBasePath = "/api/plonds/v1"; private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public PlondsReleaseUpdateService(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( public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion, Version currentVersion,
@@ -62,535 +29,52 @@ public sealed class PlondsReleaseUpdateService : IDisposable
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
} }
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync( private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion, Version currentVersion,
bool includePrerelease, bool includePrerelease,
bool isForce, bool isForce,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var normalizedCurrentVersion = NormalizeVersion(currentVersion); var releaseResult = isForce
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion); ? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
var endpoint = ResolveEndpoint(); : await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (string.IsNullOrWhiteSpace(endpoint)) if (!releaseResult.Success)
{ {
return new UpdateCheckResult( return releaseResult;
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS endpoint is not configured.",
ForceMode: isForce);
} }
try if (!isForce && !releaseResult.IsUpdateAvailable)
{ {
var apiBasePath = ResolveApiBasePath(); return releaseResult with { ForceMode = false };
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(includePrerelease);
var platform = ResolvePlatform();
var latestUrl = BuildApiUrl(
endpoint,
apiBasePath,
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
JsonElement latestNode;
try
{
latestNode = await GetJsonNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message.StartsWith("HTTP 204", StringComparison.OrdinalIgnoreCase))
{
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: normalizedCurrentVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: null,
ForceMode: isForce);
}
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: "PLONDS 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: "PLONDS 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 distributionUrl = BuildApiUrl(
endpoint,
apiBasePath,
$"distributions/{Uri.EscapeDataString(distributionId)}");
var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
var assets = ResolveInstallerAssets(distributionNode);
var payload = ResolvePlondsPayload(distributionNode, distributionId, channelId, platform);
if (assets.Count == 0 && !HasPlondsPayload(payload))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow;
var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}",
Name: $"PLONDS Distribution {latestVersionText}",
IsPrerelease: includePrerelease,
IsDraft: false,
PublishedAt: publishedAt,
Assets: assets);
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: SelectPreferredInstallerAsset(assets),
ErrorMessage: null,
ForceMode: isForce,
PlondsPayload: payload);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: $"PLONDS request failed: {ex.Message}",
ForceMode: isForce);
}
}
private async Task<JsonElement> GetJsonNodeAsync(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); if (releaseResult.PlondsPayload is not null)
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{ {
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}"); return releaseResult with { ForceMode = isForce };
} }
using var document = JsonDocument.Parse(body); var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText)
var root = document.RootElement; ? "-"
if (root.ValueKind == JsonValueKind.Object && : releaseResult.LatestVersionText;
root.TryGetProperty("content", out var content)) var message = releaseResult.Release is null
{ ? "GitHub Release data is unavailable for PLONDS."
return content.Clone(); : $"Release {latestVersion} does not expose platform PLONDS assets yet.";
}
return root.Clone(); return new UpdateCheckResult(
} Success: false,
IsUpdateAvailable: releaseResult.IsUpdateAvailable,
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode) CurrentVersionText: releaseResult.CurrentVersionText,
{ LatestVersionText: latestVersion,
var assets = new List<GitHubReleaseAsset>(); Release: releaseResult.Release,
PreferredAsset: releaseResult.PreferredAsset,
if (TryGetPropertyIgnoreCase(distributionNode, "installerMirrors", out var installersNode) && ErrorMessage: message,
installersNode.ValueKind == JsonValueKind.Array) ForceMode: isForce,
{ PlondsPayload: null);
foreach (var installerNode in installersNode.EnumerateArray())
{
if (installerNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = ReadString(installerNode, "name");
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
}
var size = ReadInt64(installerNode, "size") ?? 0L;
var sha256 = ReadString(installerNode, "sha256");
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
}
}
if (assets.Count > 0)
{
return assets;
}
if (TryGetPropertyIgnoreCase(distributionNode, "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));
}
}
return assets;
}
private static PlondsUpdatePayload ResolvePlondsPayload(
JsonElement distributionNode,
string distributionId,
string channelId,
string platform)
{
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 PlondsUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: platform,
FileMapJson: fileMapJson,
FileMapSignature: fileMapSignature,
FileMapJsonUrl: fileMapJsonUrl,
FileMapSignatureUrl: fileMapSignatureUrl);
}
private static bool HasPlondsPayload(PlondsUpdatePayload 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(bool includePrerelease)
{
return includePrerelease
? UpdateSettingsValues.ChannelPreview
: UpdateSettingsValues.ChannelStable;
}
private static string ResolvePlatform()
{
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 string? ResolveEndpoint()
{
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_ENDPOINT")
?? Environment.GetEnvironmentVariable("PLONDS_ENDPOINT");
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
}
private static string? ResolveToken()
{
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_TOKEN")
?? Environment.GetEnvironmentVariable("PLONDS_TOKEN");
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
}
private static string ResolveApiBasePath()
{
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_API_BASE_PATH")
?? Environment.GetEnvironmentVariable("PLONDS_API_BASE_PATH");
if (string.IsNullOrWhiteSpace(configured))
{
return DefaultApiBasePath;
}
var normalized = configured.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized : "/" + normalized;
}
private static string BuildApiUrl(string endpoint, string apiBasePath, string relativePath)
{
return $"{endpoint.TrimEnd('/')}/{apiBasePath.Trim('/').TrimEnd('/')}/{relativePath.TrimStart('/')}";
}
private static string? ReadString(JsonElement node, string propertyName)
{
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String
? value.GetString()
: value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
? null
: value.ToString();
}
private static long? ReadInt64(JsonElement node, string propertyName)
{
if (!TryGetPropertyIgnoreCase(node, 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 DateTimeOffset? ParsePublishedAt(JsonElement node)
{
var text = ReadString(node, "publishedAt");
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value)
? value
: null;
}
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{
if (node.ValueKind == JsonValueKind.Object)
{
foreach (var property in node.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
}
value = default;
return false;
}
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

@@ -915,6 +915,25 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
AppLogger.Warn( AppLogger.Warn(
"UpdateSettings", "UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.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 return isForce

View File

@@ -4,6 +4,7 @@ using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@@ -64,6 +65,7 @@ public sealed class UpdateWorkflowService
private const string PlondsFileMapName = "plonds-filemap.json"; private const string PlondsFileMapName = "plonds-filemap.json";
private const string PlondsFileMapSignatureName = "plonds-filemap.sig"; private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
private const string PlondsUpdateStateName = "plonds-update.json"; private const string PlondsUpdateStateName = "plonds-update.json";
private const string PlondsUpdateArchiveName = "plonds-update.zip";
private static readonly HttpClient PlondsHttpClient = new() private static readonly HttpClient PlondsHttpClient = new()
{ {
@@ -71,6 +73,7 @@ public sealed class UpdateWorkflowService
}; };
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient); private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
private const int MaxPlondsOuterRetryAttempts = 3;
public UpdateWorkflowService(ISettingsFacadeService settingsFacade) public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{ {
@@ -251,7 +254,12 @@ public sealed class UpdateWorkflowService
var payload = checkResult.PlondsPayload; var payload = checkResult.PlondsPayload;
if (payload is null) if (payload is null)
{ {
return new UpdateDownloadResult(false, null, "PLONDS payload is missing."); return await HandlePlondsDeltaFailureAsync(
checkResult,
"payload-parse",
"PLONDS payload is missing.",
progress,
cancellationToken);
} }
var incomingDir = GetLauncherIncomingDirectory(); var incomingDir = GetLauncherIncomingDirectory();
@@ -264,7 +272,12 @@ public sealed class UpdateWorkflowService
} }
catch (Exception ex) catch (Exception ex)
{ {
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}"); return await HandlePlondsDeltaFailureAsync(
checkResult,
"payload-parse",
$"Failed to create incoming directory: {ex.Message}",
progress,
cancellationToken);
} }
try try
@@ -279,79 +292,77 @@ public sealed class UpdateWorkflowService
payload.FileMapJson, payload.FileMapJson,
payload.FileMapJsonUrl, payload.FileMapJsonUrl,
fileMapPath, fileMapPath,
"file map",
"filemap-download",
cancellationToken); cancellationToken);
var fileMapSignature = await EnsurePlondsTextResourceAsync( var fileMapSignature = await EnsurePlondsTextResourceAsync(
payload.FileMapSignature, payload.FileMapSignature,
payload.FileMapSignatureUrl, payload.FileMapSignatureUrl,
signaturePath, signaturePath,
"file map signature",
"filemap-download",
cancellationToken); cancellationToken);
var downloadEntries = ParsePlondsDownloadEntries(fileMapJson); IReadOnlyList<PlondsDownloadedObjectInfo> objectResults;
if (downloadEntries.Count == 0) if (!string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl))
{ {
return new UpdateDownloadResult(false, null, "PLONDS file map does not contain downloadable objects."); progress?.Report(2d / 3d);
objectResults = await EnsurePlondsArchiveObjectsAsync(
payload,
incomingDir,
objectsDir,
state.UpdateDownloadSource,
downloadThreads,
progress,
cancellationToken);
} }
else
var expectedObjectCount = downloadEntries.Count;
var completedItems = 2;
progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2));
var objectResults = new List<PlondsDownloadedObjectInfo>(expectedObjectCount);
var objectTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var totalSteps = expectedObjectCount + 2;
foreach (var entry in downloadEntries)
{ {
if (!objectTargets.Add(entry.ObjectHashHex)) IReadOnlyList<PlondsDownloadEntry> downloadEntries;
try
{ {
completedItems++; downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
progress?.Report((double)completedItems / totalSteps); }
continue; catch (JsonException ex)
{
throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
} }
var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex); if (downloadEntries.Count == 0)
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
{ {
Directory.CreateDirectory(destinationDirectory); throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
} }
if (File.Exists(destinationPath)) var expectedObjectCount = downloadEntries.Count;
var completedItems = 2;
progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2));
var downloadResults = new List<PlondsDownloadedObjectInfo>(expectedObjectCount);
var objectTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var totalSteps = expectedObjectCount + 2;
foreach (var entry in downloadEntries)
{ {
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken); if (!objectTargets.Add(entry.ObjectHashHex))
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{ {
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++; completedItems++;
progress?.Report((double)completedItems / totalSteps); progress?.Report((double)completedItems / totalSteps);
continue; continue;
} }
var objectInfo = await EnsurePlondsObjectAsync(
entry,
objectsDir,
downloadThreads,
cancellationToken);
downloadResults.Add(objectInfo);
completedItems++;
progress?.Report((double)completedItems / totalSteps);
} }
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads); objectResults = downloadResults;
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
null,
cancellationToken);
if (!downloadResult.Success)
{
return new UpdateDownloadResult(false, null, $"Failed to download PLONDS object {entry.RelativePath}: {downloadResult.ErrorMessage}");
}
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(actualHash) &&
!string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new UpdateDownloadResult(false, null, $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
}
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++;
progress?.Report((double)completedItems / totalSteps);
} }
var updateState = new PlondsUpdateState( var updateState = new PlondsUpdateState(
@@ -390,8 +401,20 @@ public sealed class UpdateWorkflowService
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex); var stage = ex is PlondsDownloadException plondsException
return new UpdateDownloadResult(false, null, ex.Message); ? plondsException.Stage
: "payload-parse";
var message = ex is PlondsDownloadException
? ex.Message
: $"PLONDS incremental payload failed unexpectedly: {ex.Message}";
AppLogger.Warn("UpdateWorkflow", $"Failed to download PLONDS incremental payload at stage '{stage}'.", ex);
return await HandlePlondsDeltaFailureAsync(
checkResult,
stage,
message,
progress,
cancellationToken);
} }
} }
@@ -420,6 +443,125 @@ public sealed class UpdateWorkflowService
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase); || pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
} }
private async Task<UpdateDownloadResult> DownloadFullInstallerAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress,
CancellationToken cancellationToken,
bool forceRedownload)
{
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (!forceRedownload &&
existingPending is not null &&
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(
true,
existingPending.InstallerPath,
null,
verifyResult.HashMatched,
verifyResult.ExpectedHash,
verifyResult.ActualHash);
}
AppLogger.Warn(
"UpdateWorkflow",
$"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
if (forceRedownload && existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
ClearPendingUpdate();
state = _settingsFacade.Update.Get();
}
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress,
cancellationToken);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
}
private async Task<UpdateDownloadResult> HandlePlondsDeltaFailureAsync(
UpdateCheckResult checkResult,
string stage,
string errorMessage,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var normalizedMessage = string.IsNullOrWhiteSpace(errorMessage)
? $"PLONDS {stage} failed."
: $"PLONDS {stage} failed: {errorMessage}";
if (checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, normalizedMessage);
}
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS delta download failed at stage '{stage}'. Falling back to full installer download. Details: {errorMessage}");
var fallbackResult = await DownloadFullInstallerAsync(
checkResult,
progress,
cancellationToken,
forceRedownload: false);
if (fallbackResult.Success)
{
return fallbackResult;
}
var combinedMessage = string.IsNullOrWhiteSpace(fallbackResult.ErrorMessage)
? normalizedMessage
: $"{normalizedMessage} Full installer fallback failed: {fallbackResult.ErrorMessage}";
return new UpdateDownloadResult(false, null, combinedMessage);
}
private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex) private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex)
{ {
var normalizedHash = objectHashHex.Trim().ToLowerInvariant(); var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
@@ -431,6 +573,8 @@ public sealed class UpdateWorkflowService
string? inlineContent, string? inlineContent,
string? sourceUrl, string? sourceUrl,
string destinationPath, string destinationPath,
string resourceName,
string stage,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!string.IsNullOrWhiteSpace(inlineContent)) if (!string.IsNullOrWhiteSpace(inlineContent))
@@ -441,20 +585,216 @@ public sealed class UpdateWorkflowService
if (string.IsNullOrWhiteSpace(sourceUrl)) if (string.IsNullOrWhiteSpace(sourceUrl))
{ {
throw new InvalidOperationException("PLONDS payload does not contain a file map source."); throw new PlondsDownloadException(stage, $"PLONDS payload does not contain a {resourceName} source.");
} }
var downloadResult = await PlondsDownloadService.DownloadAsync( Exception? lastError = null;
sourceUrl, for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
destinationPath, {
cancellationToken: cancellationToken); var downloadResult = await PlondsDownloadService.DownloadAsync(
sourceUrl,
destinationPath,
cancellationToken: cancellationToken);
if (downloadResult.Success)
{
try
{
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
}
catch (Exception ex) when (attempt < MaxPlondsOuterRetryAttempts)
{
lastError = ex;
}
}
else
{
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS {resourceName}.");
}
if (attempt < MaxPlondsOuterRetryAttempts)
{
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS {resourceName} download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed. Retrying same URL.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
}
}
throw new PlondsDownloadException(
stage,
$"Failed to download PLONDS {resourceName} from {sourceUrl}.",
lastError);
}
private static async Task<PlondsDownloadedObjectInfo> EnsurePlondsObjectAsync(
PlondsDownloadEntry entry,
string objectsDirectory,
int downloadThreads,
CancellationToken cancellationToken)
{
var destinationPath = GetPlondsObjectDestinationPath(objectsDirectory, entry.ObjectHashHex);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
}
if (!string.IsNullOrWhiteSpace(existingHash))
{
DeleteFileIfExists(destinationPath);
}
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
var allowForcedRedownload = true;
Exception? lastError = null;
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
{
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
null,
cancellationToken);
if (!downloadResult.Success)
{
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS object {entry.RelativePath}.");
if (attempt < MaxPlondsOuterRetryAttempts)
{
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS object download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed for {entry.RelativePath}. Retrying.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
continue;
}
throw new PlondsDownloadException(
"object-download",
$"Failed to download PLONDS object {entry.RelativePath}.",
lastError);
}
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(actualHash) &&
string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
}
DeleteFileIfExists(destinationPath);
var mismatchMessage = $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash ?? "<missing>"}";
lastError = new InvalidOperationException(mismatchMessage);
if (allowForcedRedownload)
{
allowForcedRedownload = false;
AppLogger.Warn(
"UpdateWorkflow",
$"{mismatchMessage}. Removing the bad object and forcing one clean re-download.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
continue;
}
throw new PlondsDownloadException("object-verify", mismatchMessage, lastError);
}
throw new PlondsDownloadException(
"object-download",
$"Failed to download PLONDS object {entry.RelativePath}.",
lastError);
}
private async Task<IReadOnlyList<PlondsDownloadedObjectInfo>> EnsurePlondsArchiveObjectsAsync(
PlondsUpdatePayload payload,
string incomingDirectory,
string objectsDirectory,
string downloadSource,
int downloadThreads,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl))
{
throw new PlondsDownloadException("payload-parse", "PLONDS payload does not contain an update archive URL.");
}
var archiveAsset = new GitHubReleaseAsset(
Name: Path.GetFileName(payload.UpdateArchiveUrl) ?? PlondsUpdateArchiveName,
BrowserDownloadUrl: payload.UpdateArchiveUrl,
SizeBytes: payload.UpdateArchiveSizeBytes ?? 0,
Sha256: payload.UpdateArchiveSha256);
var archivePath = Path.Combine(incomingDirectory, PlondsUpdateArchiveName);
var archiveProgress = progress is null
? null
: new Progress<double>(p => progress.Report((2d + p) / 3d));
var downloadResult = await _settingsFacade.Update.DownloadAssetAsync(
archiveAsset,
archivePath,
downloadSource,
downloadThreads,
archiveProgress,
cancellationToken);
if (!downloadResult.Success) if (!downloadResult.Success)
{ {
throw new InvalidOperationException($"Failed to download PLONDS file map resource: {downloadResult.ErrorMessage}"); downloadResult = await _settingsFacade.Update.RedownloadAssetAsync(
archiveAsset,
archivePath,
downloadSource,
downloadThreads,
archiveProgress,
cancellationToken);
} }
return await File.ReadAllTextAsync(destinationPath, cancellationToken); if (!downloadResult.Success)
{
throw new PlondsDownloadException(
"object-download",
$"Failed to download PLONDS update archive: {downloadResult.ErrorMessage}");
}
try
{
if (Directory.Exists(objectsDirectory))
{
Directory.Delete(objectsDirectory, recursive: true);
}
Directory.CreateDirectory(objectsDirectory);
ZipFile.ExtractToDirectory(archivePath, objectsDirectory, overwriteFiles: true);
}
catch (Exception ex)
{
throw new PlondsDownloadException(
"payload-parse",
$"Failed to extract PLONDS update archive: {ex.Message}",
ex);
}
finally
{
DeleteFileIfExists(archivePath);
}
var objectResults = Directory.EnumerateFiles(objectsDirectory, "*", SearchOption.AllDirectories)
.Select(path => new PlondsDownloadedObjectInfo(
ComponentId: "app",
RelativePath: Path.GetRelativePath(objectsDirectory, path).Replace('\\', '/'),
SourceUrl: payload.UpdateArchiveUrl,
ObjectHashHex: Path.GetFileName(path),
LocalPath: path))
.ToArray();
progress?.Report(1d);
return objectResults;
} }
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson) private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
@@ -628,6 +968,31 @@ public sealed class UpdateWorkflowService
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant(); return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
} }
private static void DeleteFileIfExists(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Best effort cleanup only. The caller still verifies the resulting payload before it is applied.
}
}
private static TimeSpan GetPlondsRetryDelay(int attempt)
{
return attempt switch
{
1 => TimeSpan.FromMilliseconds(350),
2 => TimeSpan.FromMilliseconds(900),
_ => TimeSpan.FromMilliseconds(1500)
};
}
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{ {
if (node.ValueKind == JsonValueKind.Object) if (node.ValueKind == JsonValueKind.Object)
@@ -742,6 +1107,17 @@ public sealed class UpdateWorkflowService
string DownloadUrl, string DownloadUrl,
string ObjectHashHex); string ObjectHashHex);
private sealed class PlondsDownloadException : Exception
{
public PlondsDownloadException(string stage, string message, Exception? innerException = null)
: base(message, innerException)
{
Stage = stage;
}
public string Stage { get; }
}
private sealed record PlondsDownloadedObjectInfo( private sealed record PlondsDownloadedObjectInfo(
string ComponentId, string ComponentId,
string RelativePath, string RelativePath,
@@ -876,53 +1252,11 @@ public sealed class UpdateWorkflowService
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
} }
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) return await DownloadFullInstallerAsync(
{ checkResult,
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null &&
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
}
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress, progress,
cancellationToken); cancellationToken,
forceRedownload: false);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
} }
public async Task<UpdateDownloadResult> RedownloadReleaseAsync( public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
@@ -938,58 +1272,11 @@ public sealed class UpdateWorkflowService
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
} }
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) return await DownloadFullInstallerAsync(
{ checkResult,
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
}
ClearPendingUpdate();
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
state = _settingsFacade.Update.Get();
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress, progress,
cancellationToken); cancellationToken,
forceRedownload: true);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
} }
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync() public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()

View File

@@ -1965,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
return; return;
} }
if (result.PreferredAsset is null) if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{ {
UpdateStatus = isForce UpdateStatus = isForce
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.") ? 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))] [RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
private async Task RedownloadUpdateAsync() 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."); UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
return; return;
@@ -2233,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateDownloadResult downloadResult; UpdateDownloadResult downloadResult;
// Prefer delta update if available (smaller download, faster) // 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..."); UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress); downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
if (!downloadResult.Success) if (!downloadResult.Success && result.PlondsPayload is null)
{ {
// Delta download failed, fall back to full installer // Delta download failed, fall back to full installer
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer."); AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");

View File

@@ -1,10 +1,10 @@
# PLONDS Skeleton # PLONDS 骨架
Penguin Logistics Online Network Distribution System, or PLONDS, is the standalone update-distribution skeleton for LanMountainDesktop. Penguin Logistics Online Network Distribution System(企鹅物流在线网络分发系统),简称 PLONDS LanMountainDesktop 的独立更新分发骨架。
This directory is intentionally isolated from the main app and Launcher. It contains only the new distribution protocol, a thin read-only API, and sample S3-style metadata files. 本目录有意与主应用和启动器隔离,仅包含新的分发协议、一个轻量级的只读 API以及示例 S3 风格的元数据文件。
## Directory Layout ## 目录结构
```text ```text
PenguinLogisticsOnlineNetworkDistributionSystem/ PenguinLogisticsOnlineNetworkDistributionSystem/
@@ -22,72 +22,72 @@ PenguinLogisticsOnlineNetworkDistributionSystem/
distributions/ distributions/
``` ```
## Projects ## 项目说明
- `Plonds.Shared` provides protocol constants and models. - `Plonds.Shared` 提供协议常量和数据模型。
- `Plonds.Core` owns hashing, diffing, object-repo generation, manifest generation, signing, and publish orchestration. - `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。
- `Plonds.Tool` is the CI-facing CLI entrypoint. PowerShell should stay as a thin wrapper around this tool. - `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。
- `Plonds.Api` is a thin read-only API that reads metadata from a local folder laid out like S3. - `Plonds.Api` 是一个轻量级只读 API从类似 S3 布局的本地文件夹中读取元数据。
## Architecture ## 架构设计
PLONDS is intentionally built around a single C# implementation stack so the protocol and publish behavior do not drift across languages. PLONDS 有意围绕单一的 C# 实现栈构建,以确保协议和发布行为不会在不同语言之间产生偏差。
```text ```text
Host App 宿主应用
-> checks updates, downloads objects, stages incoming payload -> 检查更新、下载对象、暂存传入的负载
Launcher 启动器
-> verifies signature, applies file map, switches deployment, rolls back -> 验证签名、应用文件映射、切换部署、回滚
PLONDS.Api PLONDS.Api
-> read-only metadata projection for clients -> 面向客户端的只读元数据投影
PLONDS.Tool PLONDS.Tool
-> CI/release command surface -> CI/发布命令界面
PLONDS.Core PLONDS.Core
-> hash/diff/object-repo/sign/publish implementation -> 哈希/差异/对象仓库/签名/发布实现
PLONDS.Shared PLONDS.Shared
-> protocol constants and DTOs -> 协议常量和 DTO
``` ```
Rules for v1: ## v1 规则
- Core protocol behavior should live in `Plonds.Core`, not in PowerShell scripts. - 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。
- `scripts/*.ps1` may remain only as thin wrappers for GitHub Actions and local convenience. - `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。
- Host keeps download responsibility. - 宿主应用保留下载职责。
- Launcher keeps apply, atomic switch, snapshot, and rollback responsibility. - 启动器保留应用、原子切换、快照和回滚职责。
## Storage Layout ## 存储布局
The first version keeps one fixed object root: 第一版本保持固定的对象根目录:
```text ```text
lanmountain/update/ lanmountain/update/
repo/sha256/<prefix>/<hash> repo/sha256/<前缀>/<哈希>
meta/channels/<channel>/<platform>/latest.json meta/channels/<频道>/<平台>/latest.json
meta/distributions/<distributionId>.json meta/distributions/<分发ID>.json
installers/<platform>/<version>/... installers/<平台>/<版本>/...
``` ```
Planned but not enabled in v1: 已规划但 v1 中未启用:
```text ```text
lanmountain/update/repo-compressed/<algo>/<prefix>/<hash> lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希>
lanmountain/update/patches/<algo>/<baseHash>/<targetHash> lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希>
``` ```
## Public Endpoints ## 公共接口
The API base path is `/api/plonds/v1`. API 基础路径为 `/api/plonds/v1`
- `GET /healthz` - `GET /healthz` - 健康检查
- `GET /api/plonds/v1/metadata` - `GET /api/plonds/v1/metadata` - 获取元数据目录
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本
- `GET /api/plonds/v1/distributions/{distributionId}` - `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息
## Local Run ## 本地运行
```powershell ```powershell
dotnet run --project src/Plonds.Api dotnet run --project src/Plonds.Api
``` ```
By default the API reads metadata from `sample-data`. 默认情况下API 从 `sample-data` 读取元数据。

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,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

@@ -13,4 +13,11 @@ public sealed record PlondsGenerateOptions(
string? FileMapUrl = null, string? FileMapUrl = null,
string? FileMapSignatureUrl = null, string? FileMapSignatureUrl = null,
string? InstallerDirectory = null, string? InstallerDirectory = null,
string? InstallerBaseUrl = 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

@@ -42,11 +42,16 @@ public sealed class PlondsGenerator
Directory.CreateDirectory(metaDistributionRoot); Directory.CreateDirectory(metaDistributionRoot);
Directory.CreateDirectory(metaChannelRoot); Directory.CreateDirectory(metaChannelRoot);
var previousManifest = ScanDirectory(previousDirectory); var previousManifest = options.IsFullPayloadRelease
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: ScanDirectory(previousDirectory);
var currentManifest = ScanDirectory(currentDirectory); var currentManifest = ScanDirectory(currentDirectory);
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl); var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl); var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
var publishedAt = DateTimeOffset.UtcNow; var publishedAt = DateTimeOffset.UtcNow;
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var fileMap = new FileMapDocument( var fileMap = new FileMapDocument(
FormatVersion: "1.0", FormatVersion: "1.0",
@@ -69,7 +74,14 @@ public sealed class PlondsGenerator
Metadata: new Dictionary<string, string> Metadata: new Dictionary<string, string>
{ {
["protocol"] = "PLONDS", ["protocol"] = "PLONDS",
["mode"] = "file-object" ["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( var distribution = new DistributionDocument(
@@ -83,7 +95,17 @@ public sealed class PlondsGenerator
Components: fileMap.Components, Components: fileMap.Components,
InstallerMirrors: installerMirrors, InstallerMirrors: installerMirrors,
Capabilities: ["file-object"], Capabilities: ["file-object"],
Metadata: new Dictionary<string, string> { ["protocol"] = "PLONDS" }); 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( var latest = new LatestPointerDocument(
DistributionId: distributionId, DistributionId: distributionId,
@@ -225,6 +247,7 @@ public sealed class PlondsGenerator
Platform: platform, Platform: platform,
Arch: ResolveArch(platform), Arch: ResolveArch(platform),
Url: url, Url: url,
Name: fileName,
FileName: fileName, FileName: fileName,
Sha256: ComputeSha256(destinationPath), Sha256: ComputeSha256(destinationPath),
Size: new FileInfo(destinationPath).Length)); Size: new FileInfo(destinationPath).Length));
@@ -345,6 +368,7 @@ public sealed class PlondsGenerator
string Platform, string Platform,
string Arch, string Arch,
string? Url, string? Url,
string? Name,
string? FileName, string? FileName,
string? Sha256, string? Sha256,
long Size); long Size);

View File

@@ -9,4 +9,11 @@ public sealed record PlondsPublishOptions(
string Channel = "stable", string Channel = "stable",
string? BaselineRoot = null, string? BaselineRoot = null,
string? RepoBaseUrl = null, string? RepoBaseUrl = null,
string? InstallerBaseUrl = 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

@@ -82,7 +82,7 @@ public sealed class PlondsPublisher
CurrentDirectory: currentAppDirectory, CurrentDirectory: currentAppDirectory,
Platform: config.Platform, Platform: config.Platform,
OutputRoot: options.OutputRoot, OutputRoot: options.OutputRoot,
PreviousVersion: previousVersion, PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
PreviousDirectory: previousDirectory, PreviousDirectory: previousDirectory,
Channel: options.Channel, Channel: options.Channel,
DistributionId: distributionId, DistributionId: distributionId,
@@ -90,7 +90,14 @@ public sealed class PlondsPublisher
FileMapUrl: fileMapUrl, FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapSignatureUrl, FileMapSignatureUrl: fileMapSignatureUrl,
InstallerDirectory: installerSourceDirectory, InstallerDirectory: installerSourceDirectory,
InstallerBaseUrl: installerBaseUrl)); 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); _signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);

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,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,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

@@ -1,4 +1,4 @@
using Plonds.Core.Publishing; using Plonds.Core.Publishing;
using Plonds.Core.Security; using Plonds.Core.Security;
return await PlondsCli.RunAsync(args); return await PlondsCli.RunAsync(args);
@@ -29,6 +29,18 @@ internal static class PlondsCli
case "publish": case "publish":
RunPublish(options); RunPublish(options);
return Task.FromResult(0); 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: default:
Console.Error.WriteLine($"Unknown command: {command}"); Console.Error.WriteLine($"Unknown command: {command}");
PrintUsage(); PrintUsage();
@@ -86,7 +98,14 @@ internal static class PlondsCli
Channel: Get(options, "channel", "stable") ?? "stable", Channel: Get(options, "channel", "stable") ?? "stable",
BaselineRoot: Get(options, "baseline-root"), BaselineRoot: Get(options, "baseline-root"),
RepoBaseUrl: Get(options, "repo-base-url"), RepoBaseUrl: Get(options, "repo-base-url"),
InstallerBaseUrl: Get(options, "installer-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) foreach (var result in results)
{ {
@@ -94,6 +113,62 @@ internal static class PlondsCli
} }
} }
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) private static Dictionary<string, string> ParseOptions(string[] args)
{ {
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -135,8 +210,12 @@ internal static class PlondsCli
private static void PrintUsage() private static void PrintUsage()
{ {
Console.WriteLine("PLONDS Tool"); Console.WriteLine("PLONDS Tool");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]"); 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(" 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>]"); Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
} }
} }

File diff suppressed because it is too large Load Diff