mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Rebuild release pipeline around PLONDS and DDSS
This commit is contained in:
166
.github/workflows/ddss-publish.yml
vendored
Normal file
166
.github/workflows/ddss-publish.yml
vendored
Normal 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
235
.github/workflows/plonds-build.yml
vendored
Normal 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
|
||||
647
.github/workflows/release.yml
vendored
647
.github/workflows/release.yml
vendored
@@ -15,23 +15,6 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
incremental_strategy:
|
||||
description: 'Incremental strategy'
|
||||
required: false
|
||||
type: choice
|
||||
default: release-payload
|
||||
options:
|
||||
- release-payload
|
||||
- commit-range
|
||||
publish_incremental_release:
|
||||
description: 'Publish as incremental release'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
baseline_ref:
|
||||
description: 'Optional baseline tag/version/commit'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
@@ -47,6 +30,8 @@ jobs:
|
||||
informational_version: ${{ steps.version.outputs.informational_version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
release_channel: ${{ steps.version.outputs.release_channel }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository metadata
|
||||
@@ -60,6 +45,7 @@ jobs:
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
CHECKOUT_REF="${GITHUB_REF}"
|
||||
IS_PRERELEASE="false"
|
||||
else
|
||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
||||
@@ -69,23 +55,40 @@ jobs:
|
||||
else
|
||||
TAG="v${RAW_TAG}"
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
CHECKOUT_REF="refs/tags/${TAG}"
|
||||
else
|
||||
CHECKOUT_REF="${GITHUB_SHA}"
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
|
||||
IS_PRERELEASE="true"
|
||||
else
|
||||
IS_PRERELEASE="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
||||
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
||||
VERSION_PARTS+=("0")
|
||||
done
|
||||
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||
RELEASE_CHANNEL="preview"
|
||||
else
|
||||
RELEASE_CHANNEL="stable"
|
||||
fi
|
||||
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-windows:
|
||||
needs: prepare
|
||||
@@ -133,9 +136,6 @@ jobs:
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
|
||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
||||
|
||||
# AOT publish
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||
-c Release `
|
||||
-o ./$launcherPublishDir `
|
||||
@@ -156,21 +156,6 @@ jobs:
|
||||
Write-Error "Launcher AOT publish failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# é<>„剧ã<C2A7>šé<C5A1>™æˆ<C3A6>ç«·ç¼<C3A7>æ’´ç<C2B4>?
|
||||
Write-Host "Launcher published to: $launcherPublishDir"
|
||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
$size = [Math]::Round($exeFile.Length / 1MB, 2)
|
||||
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
||||
}
|
||||
|
||||
# Warn if unexpected extra files are produced
|
||||
$files = Get-ChildItem -Path $launcherPublishDir -File
|
||||
if ($files.Count -gt 1) {
|
||||
Write-Host "Warning: Expected single file but found $($files.Count) files"
|
||||
$files | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish Main App
|
||||
@@ -208,9 +193,6 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
}
|
||||
|
||||
Write-Host "Published to: $publishDir"
|
||||
Write-Host "Self-contained: $selfContained"
|
||||
shell: pwsh
|
||||
|
||||
- name: Restructure for Launcher
|
||||
@@ -221,30 +203,18 @@ jobs:
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
$appDir = "app-$version"
|
||||
|
||||
Write-Host "Restructuring for Launcher mode..."
|
||||
Write-Host "Version: $version"
|
||||
Write-Host "Publish dir: $publishDir"
|
||||
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||
|
||||
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||
$appPath = Join-Path $newStructure $appDir
|
||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||
|
||||
$launcherSource = $launcherPublishDir
|
||||
if (Test-Path $launcherSource) {
|
||||
Write-Host "Copying Launcher to root..."
|
||||
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
|
||||
} else {
|
||||
Write-Warning "Launcher publish dir not found: $launcherSource"
|
||||
if (Test-Path $launcherPublishDir) {
|
||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||
|
||||
Write-Host "New directory structure:"
|
||||
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
|
||||
|
||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
@@ -260,61 +230,27 @@ jobs:
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$suffix = "${{ matrix.suffix }}"
|
||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$outputDir = "build-installer"
|
||||
|
||||
if (-not (Test-Path -Path $publishDir)) {
|
||||
Write-Error "Publish directory not found: $publishDir"
|
||||
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
|
||||
exit 1
|
||||
}
|
||||
$installerScript = "LanMountainDesktop/packaging/windows/LanMountainDesktop.iss"
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
if (-not (Test-Path -Path $installerScript)) {
|
||||
Write-Error "Installer script not found: $installerScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$isccPath = $null
|
||||
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
||||
if ($isccCommand) {
|
||||
$isccPath = $isccCommand.Source
|
||||
}
|
||||
|
||||
$candidatePaths = @(
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
||||
"$env:ChocolateyInstall\bin\ISCC.exe",
|
||||
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
|
||||
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
||||
)
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
|
||||
$isccPath = $candidatePaths | Select-Object -First 1
|
||||
if (-not $isccPath) {
|
||||
foreach ($candidate in $candidatePaths) {
|
||||
if ($candidate -and (Test-Path -Path $candidate)) {
|
||||
$isccPath = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isccPath) {
|
||||
Write-Host "ISCC.exe was not found in PATH or known locations."
|
||||
Write-Host "Checked locations:"
|
||||
$candidatePaths | ForEach-Object { Write-Host " - $_" }
|
||||
Write-Host "Chocolatey bin listing (if exists):"
|
||||
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
|
||||
Write-Error "Inno Setup compiler not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found Inno Setup at: $isccPath"
|
||||
|
||||
Write-Host "Building installer for Windows $arch with version $version..."
|
||||
|
||||
$publishDir = (Resolve-Path $publishDir).Path
|
||||
$outputDir = (Resolve-Path $outputDir).Path
|
||||
$installerScript = (Resolve-Path $installerScript).Path
|
||||
@@ -329,8 +265,6 @@ jobs:
|
||||
$installerScript
|
||||
)
|
||||
|
||||
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
|
||||
|
||||
& $isccPath @compileArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
||||
@@ -342,25 +276,53 @@ jobs:
|
||||
Write-Error "Failed to create installer"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Successfully created: $($installerFile.Name)"
|
||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload App Payload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-payload-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
publish/windows-${{ matrix.arch }}/**
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||
if (-not (Test-Path $payloadRoot)) {
|
||||
Write-Error "Payload root not found: $payloadRoot"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload Installer
|
||||
$stageDir = Join-Path $PWD "payload-stage/windows-$arch"
|
||||
$releaseDir = Join-Path $PWD "release-assets"
|
||||
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
|
||||
|
||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
return
|
||||
}
|
||||
|
||||
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
|
||||
$destinationDir = Split-Path -Path $destination -Parent
|
||||
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
|
||||
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
}
|
||||
|
||||
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
|
||||
if (Test-Path $payloadZip) {
|
||||
Remove-Item $payloadZip -Force
|
||||
}
|
||||
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-windows-${{ matrix.arch }}
|
||||
path: build-installer/*.exe
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
release-assets/files-windows-${{ matrix.arch }}.zip
|
||||
build-installer/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
@@ -385,13 +347,10 @@ jobs:
|
||||
libx11-6 libxrandr2 libxinerama1 \
|
||||
libxi6 libxcursor1 libxext6 \
|
||||
libxrender1 libxkbcommon-x11-0 \
|
||||
clang zlib1g-dev
|
||||
clang zlib1g-dev zip rsync
|
||||
|
||||
# Ubuntu 24.04+ moved several packages to t64 names.
|
||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||
|
||||
# Prefer modern WebKit package, fallback for older images.
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Setup .NET
|
||||
@@ -413,8 +372,6 @@ jobs:
|
||||
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for Linux x64..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-linux-x64 \
|
||||
@@ -431,14 +388,6 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-linux-x64"
|
||||
ls -lh ./publish/launcher-linux-x64/
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||
@@ -464,25 +413,15 @@ jobs:
|
||||
appDir="app-$version"
|
||||
launcherDir="publish/launcher-linux-x64"
|
||||
|
||||
echo "Restructuring for Launcher mode..."
|
||||
echo "Version: $version"
|
||||
|
||||
mkdir -p "$publishDir"
|
||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||
|
||||
if [ -d "$launcherDir" ]; then
|
||||
echo "Copying Launcher to root..."
|
||||
cp -r "$launcherDir"/* "$publishDir/"
|
||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
||||
fi
|
||||
|
||||
touch "$publishDir/$appDir/.current"
|
||||
|
||||
echo "New directory structure:"
|
||||
find "$publishDir" -maxdepth 2 | head -50
|
||||
|
||||
rm -rf "$launcherDir"
|
||||
|
||||
- name: Package as DEB
|
||||
@@ -495,12 +434,6 @@ jobs:
|
||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
||||
|
||||
if [ ! -d "$source" ]; then
|
||||
echo "Error: Source directory not found: $source"
|
||||
ls -la publish/ || echo "publish directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "build-deb/DEBIAN"
|
||||
mkdir -p "build-deb/usr/local/bin"
|
||||
mkdir -p "build-deb/usr/share/applications"
|
||||
@@ -509,20 +442,6 @@ jobs:
|
||||
|
||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
||||
|
||||
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
|
||||
echo "DEB package contains $item_count files"
|
||||
|
||||
if [ "$item_count" -eq 0 ]; then
|
||||
echo "Error: DEB package is empty after copy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
|
||||
echo "Error: Linux desktop resources are missing"
|
||||
ls -la "LanMountainDesktop/packaging/linux" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||
@@ -546,9 +465,9 @@ jobs:
|
||||
printf '%s\n' "Package: $package_name"
|
||||
printf '%s\n' "Version: $package_version"
|
||||
printf '%s\n' "Architecture: $arch"
|
||||
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
|
||||
printf '%s\n' "Description: LanMountain Desktop Application"
|
||||
printf '%s\n' " A desktop application for LanMountain."
|
||||
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
|
||||
printf '%s\n' 'Description: LanMountain Desktop Application'
|
||||
printf '%s\n' ' A desktop application for LanMountain.'
|
||||
} > "build-deb/DEBIAN/control"
|
||||
|
||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
|
||||
@@ -557,35 +476,49 @@ jobs:
|
||||
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||
chmod 755 "build-deb/DEBIAN/postinst"
|
||||
|
||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
||||
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
|
||||
ls -lh "${package_name}_${package_version}_${arch}.deb"
|
||||
else
|
||||
echo "Error: Failed to build DEB package"
|
||||
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
|
||||
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
payload_root="publish/linux-x64/app-$version"
|
||||
release_dir="$PWD/release-assets"
|
||||
stage_dir="$PWD/payload-stage/linux-x64"
|
||||
|
||||
if [ ! -d "$payload_root" ]; then
|
||||
echo "Payload root not found: $payload_root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload App Payload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-payload-linux-x64
|
||||
path: |
|
||||
publish/linux-x64/**
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
rm -rf "$stage_dir"
|
||||
mkdir -p "$stage_dir" "$release_dir"
|
||||
rsync -a \
|
||||
--exclude '.current' \
|
||||
--exclude '.partial' \
|
||||
--exclude '.destroy' \
|
||||
"$payload_root/" "$stage_dir/"
|
||||
|
||||
- name: Upload Installer
|
||||
(
|
||||
cd "$stage_dir"
|
||||
zip -qr "$release_dir/files-linux-x64.zip" .
|
||||
)
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-linux-x64
|
||||
path: "*.deb"
|
||||
name: release-linux-x64
|
||||
path: |
|
||||
release-assets/files-linux-x64.zip
|
||||
*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
needs: prepare
|
||||
runs-on: macos-latest
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
name: Build_macOS_${{ matrix.arch }}
|
||||
@@ -620,8 +553,6 @@ jobs:
|
||||
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||
@@ -638,14 +569,6 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
|
||||
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||
@@ -664,7 +587,22 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
release_dir="$PWD/release-assets"
|
||||
stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}"
|
||||
payload_root="publish/macos-${{ matrix.arch }}-app"
|
||||
|
||||
rm -rf "$stage_dir"
|
||||
mkdir -p "$stage_dir" "$release_dir"
|
||||
rsync -a "$payload_root/" "$stage_dir/"
|
||||
(
|
||||
cd "$stage_dir"
|
||||
zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" .
|
||||
)
|
||||
|
||||
- name: Restructure and Package as DMG
|
||||
continue-on-error: true
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
arch="${{ matrix.arch }}"
|
||||
@@ -673,41 +611,19 @@ jobs:
|
||||
launcherDir="publish/launcher-macos-$arch"
|
||||
appSourceDir="publish/macos-$arch-app"
|
||||
|
||||
echo "Restructuring for Launcher mode..."
|
||||
echo "Version: $version"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||
|
||||
appDir="app-$version"
|
||||
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
||||
|
||||
if [ -d "$appSourceDir" ]; then
|
||||
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||
else
|
||||
echo "Error: Main app source directory not found: $appSourceDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||
if [ -d "$launcherDir" ]; then
|
||||
echo "Copying Launcher to root..."
|
||||
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
||||
fi
|
||||
|
||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/Resources"
|
||||
|
||||
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
|
||||
echo "App bundle contains $item_count files"
|
||||
|
||||
if [ "$item_count" -eq 0 ]; then
|
||||
echo "Error: App bundle is empty after copy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
@@ -731,306 +647,73 @@ jobs:
|
||||
|
||||
mkdir -p dmg-temp
|
||||
cp -r "${app_name}.app" dmg-temp/
|
||||
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
|
||||
|
||||
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
|
||||
echo "Successfully created: ${package_name}.dmg"
|
||||
ls -lh "${package_name}.dmg"
|
||||
else
|
||||
echo "Error: Failed to create DMG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf dmg-temp "${app_name}.app"
|
||||
|
||||
- name: Upload
|
||||
- name: Upload Release Assets
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-macos-${{ matrix.arch }}
|
||||
path: "*.dmg"
|
||||
if-no-files-found: error
|
||||
name: release-macos-${{ matrix.arch }}
|
||||
path: |
|
||||
release-assets/files-macos-${{ matrix.arch }}.zip
|
||||
*.dmg
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
publish-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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||
|
||||
- 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"
|
||||
$incrementalStrategy = if ("${{ github.event_name }}" -eq "workflow_dispatch" -and -not [string]::IsNullOrWhiteSpace("${{ github.event.inputs.incremental_strategy }}")) {
|
||||
"${{ github.event.inputs.incremental_strategy }}"
|
||||
} else {
|
||||
"release-payload"
|
||||
}
|
||||
$publishIncrementalRelease = if ("${{ github.event_name }}" -eq "workflow_dispatch" -and -not [string]::IsNullOrWhiteSpace("${{ github.event.inputs.publish_incremental_release }}")) {
|
||||
"${{ github.event.inputs.publish_incremental_release }}"
|
||||
} else {
|
||||
"true"
|
||||
}
|
||||
$baselineRef = if ("${{ github.event_name }}" -eq "workflow_dispatch") {
|
||||
"${{ github.event.inputs.baseline_ref }}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
./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 `
|
||||
-IncrementalStrategy $incrementalStrategy `
|
||||
-PublishIncrementalRelease $publishIncrementalRelease `
|
||||
-BaselineRef $baselineRef `
|
||||
-GitHubRepository "${{ github.repository }}" `
|
||||
-GitHubTag "${{ needs.prepare.outputs.tag }}" `
|
||||
-MirrorInstallersToS3 "false" `
|
||||
-UploadMetaToS3 "false"
|
||||
|
||||
- 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:
|
||||
needs: [ prepare, build-windows, build-linux, build-macos, publish-plonds ]
|
||||
needs: [prepare, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download installer artifacts
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/installers
|
||||
pattern: installer-*
|
||||
path: release-files
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download PLONDS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/plonds
|
||||
pattern: plonds-assets
|
||||
|
||||
- name: List artifacts structure
|
||||
- name: Validate release files
|
||||
run: |
|
||||
echo "Artifact directory structure:"
|
||||
find artifacts -type f -o -type d | sort
|
||||
echo ""
|
||||
echo "Files found:"
|
||||
find artifacts -type f -exec ls -lh {} \;
|
||||
echo ""
|
||||
echo "Full tree:"
|
||||
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
|
||||
echo "Release files:"
|
||||
find release-files -maxdepth 1 -type f -exec ls -lh {} \;
|
||||
|
||||
- name: Flatten artifacts for release
|
||||
run: |
|
||||
echo "Organizing artifacts..."
|
||||
mkdir -p release-files
|
||||
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||
find artifacts/plonds -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" -o -name "plonds-*.json" -o -name "plonds-*.json.sig" -o -name "plonds-payload-*.zip" \) -exec cp -v {} release-files/ \;
|
||||
echo ""
|
||||
echo "Files ready for release:"
|
||||
ls -lh release-files/ || echo "No files found in release-files"
|
||||
echo ""
|
||||
echo "Total files:"
|
||||
file_count=$(find release-files -type f | wc -l)
|
||||
echo "$file_count"
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "Error: No release files found"
|
||||
if [ ! -f release-files/files-windows-x64.zip ] || [ ! -f release-files/files-windows-x86.zip ] || [ ! -f release-files/files-linux-x64.zip ]; then
|
||||
echo "Required payload zips are missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
file_count=$(find release-files -maxdepth 1 -type f | wc -l)
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "No release files were produced."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create or Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
name: ${{ needs.prepare.outputs.tag }}
|
||||
commit: ${{ github.sha }}
|
||||
allowUpdates: true
|
||||
draft: false
|
||||
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
|
||||
artifacts: "release-files/**"
|
||||
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
artifacts: 'release-files/**'
|
||||
body: |
|
||||
## Release ${{ needs.prepare.outputs.version }}
|
||||
|
||||
### Windows
|
||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
|
||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
|
||||
### Installers
|
||||
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
|
||||
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
|
||||
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
|
||||
|
||||
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
|
||||
|
||||
Installation: Double-click the .exe file and follow the wizard.
|
||||
|
||||
### Incremental Update Assets
|
||||
- **plonds-filemap-windows-x64.json / plonds-filemap-windows-x64.json.sig**
|
||||
- **plonds-filemap-windows-x86.json / plonds-filemap-windows-x86.json.sig**
|
||||
- **plonds-filemap-linux-x64.json / plonds-filemap-linux-x64.json.sig**
|
||||
- **plonds-payload-windows-x64.zip**
|
||||
- **plonds-payload-windows-x86.zip**
|
||||
- **plonds-payload-linux-x64.zip**
|
||||
|
||||
### 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)
|
||||
### Payload Archives
|
||||
- `files-windows-x64.zip`
|
||||
- `files-windows-x86.zip`
|
||||
- `files-linux-x64.zip`
|
||||
|
||||
### macOS
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
||||
- macOS assets are best-effort and will not block the release.
|
||||
|
||||
See commits for changes.
|
||||
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-plonds-meta:
|
||||
needs: [ prepare, publish-plonds, github-release ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
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:
|
||||
- name: Download PLONDS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/plonds
|
||||
pattern: plonds-assets
|
||||
|
||||
- name: Publish PLONDS meta to S3
|
||||
if: ${{ env.S3_ENDPOINT != '' && env.S3_BUCKET != '' && env.S3_ACCESS_KEY != '' && env.S3_SECRET_KEY != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
meta_dir="$(find artifacts/plonds -type d -path '*/published/meta' | head -n 1)"
|
||||
if [ -z "${meta_dir}" ]; then
|
||||
echo "Unable to locate published/meta inside PLONDS artifacts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing PLONDS meta from ${meta_dir}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 cp "$meta_dir" "s3://$S3_BUCKET/lanmountain/update/meta/" --recursive --only-show-errors --no-progress
|
||||
|
||||
@@ -465,6 +465,7 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
ApplyUnixFileModeIfPresent(targetPath, file);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -472,6 +473,7 @@ internal sealed class UpdateEngineService
|
||||
var objectBytes = File.ReadAllBytes(objectPath);
|
||||
var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes;
|
||||
File.WriteAllBytes(targetPath, restoredBytes);
|
||||
ApplyUnixFileModeIfPresent(targetPath, file);
|
||||
}
|
||||
|
||||
private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment)
|
||||
@@ -914,6 +916,29 @@ internal sealed class UpdateEngineService
|
||||
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
|
||||
{
|
||||
Path = path,
|
||||
@@ -954,6 +979,31 @@ internal sealed class UpdateEngineService
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.Metadata.TryGetValue("unixFileMode", out var rawMode) ||
|
||||
string.IsNullOrWhiteSpace(rawMode))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var normalized = rawMode.Trim();
|
||||
var modeValue = Convert.ToInt32(normalized, 8);
|
||||
File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort only. A bad mode should not break the entire update.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (node.ValueKind == JsonValueKind.Object)
|
||||
|
||||
@@ -44,7 +44,10 @@ public sealed record PlondsUpdatePayload(
|
||||
string? FileMapJson,
|
||||
string? FileMapSignature,
|
||||
string? FileMapJsonUrl,
|
||||
string? FileMapSignatureUrl);
|
||||
string? FileMapSignatureUrl,
|
||||
string? UpdateArchiveUrl = null,
|
||||
string? UpdateArchiveSha256 = null,
|
||||
long? UpdateArchiveSizeBytes = null);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
@@ -159,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
var preferredAsset = isUpdateAvailable
|
||||
? SelectPreferredInstallerAsset(release.Assets)
|
||||
: null;
|
||||
var plondsPayload = isUpdateAvailable
|
||||
? TryResolvePlondsPayload(release)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
@@ -167,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null);
|
||||
ErrorMessage: null,
|
||||
PlondsPayload: plondsPayload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -232,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
@@ -241,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: true);
|
||||
ForceMode: true,
|
||||
PlondsPayload: plondsPayload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -652,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
||||
if (assets is null || assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -664,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
var ranked = assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreLinuxInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreMacInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||
{
|
||||
if (release.Assets is null || release.Assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var platformSuffix = GetPlatformAssetSuffix();
|
||||
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||
var channelId = release.IsPrerelease
|
||||
? UpdateSettingsValues.ChannelPreview
|
||||
: UpdateSettingsValues.ChannelStable;
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: platformSuffix,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||
{
|
||||
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
|
||||
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
|
||||
@@ -719,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int ScoreLinuxInstallerAsset(string assetName, string architectureToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetName))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (assetName.EndsWith(".deb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 220;
|
||||
}
|
||||
else if (assetName.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 180;
|
||||
}
|
||||
else if (assetName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 160;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (assetName.Contains("linux", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase) ||
|
||||
(architectureToken == "x64" && assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int ScoreMacInstallerAsset(string assetName, string architectureToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetName))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (assetName.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 220;
|
||||
}
|
||||
else if (assetName.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 180;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (assetName.Contains("mac", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("osx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version? version)
|
||||
{
|
||||
version = null;
|
||||
|
||||
@@ -1,52 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin PLONDS client used by the host app.
|
||||
/// The host keeps responsibility for checking and downloading updates; Launcher only applies staged payloads.
|
||||
/// Release-backed PLONDS checker.
|
||||
/// It only succeeds when the latest GitHub Release already exposes platform PLONDS assets.
|
||||
/// If those assets are not ready yet, callers can fall back to the normal GitHub installer flow.
|
||||
/// </summary>
|
||||
public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
{
|
||||
private const string DefaultApiBasePath = "/api/plonds/v1";
|
||||
private const int MaxTransientRetryAttempts = 3;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
|
||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
@@ -64,829 +29,52 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_githubReleaseUpdateService.Dispose();
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
var endpoint = ResolveEndpoint();
|
||||
var latestVersionText = "-";
|
||||
var releaseResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
if (!releaseResult.Success)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PLONDS endpoint is not configured.",
|
||||
ForceMode: isForce);
|
||||
return releaseResult;
|
||||
}
|
||||
|
||||
try
|
||||
if (!isForce && !releaseResult.IsUpdateAvailable)
|
||||
{
|
||||
var apiBasePath = ResolveApiBasePath();
|
||||
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
|
||||
var channelId = ResolveChannelId(includePrerelease);
|
||||
var platform = ResolvePlatform();
|
||||
var latestUrl = BuildApiUrl(
|
||||
endpoint,
|
||||
apiBasePath,
|
||||
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
|
||||
|
||||
_ = await GetJsonNodeWithRetryAsync(metadataUrl, PlondsCheckStage.Metadata, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestDescriptor = await GetLatestDescriptorAsync(
|
||||
latestUrl,
|
||||
allowNoUpdateResponse: true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (latestDescriptor is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: normalizedCurrentVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
latestVersionText = latestDescriptor.VersionText;
|
||||
var hasUpdate = latestDescriptor.Version > normalizedCurrentVersion;
|
||||
if (!isForce && !hasUpdate)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: false);
|
||||
}
|
||||
|
||||
var distribution = await ResolveDistributionAsync(
|
||||
endpoint,
|
||||
apiBasePath,
|
||||
latestUrl,
|
||||
latestDescriptor,
|
||||
channelId,
|
||||
platform,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
latestVersionText = distribution.Latest.VersionText;
|
||||
|
||||
var publishedAt = ParsePublishedAt(distribution.DistributionNode) ?? DateTimeOffset.UtcNow;
|
||||
var release = new GitHubReleaseInfo(
|
||||
TagName: $"v{distribution.Latest.VersionText}",
|
||||
Name: $"PLONDS Distribution {distribution.Latest.VersionText}",
|
||||
IsPrerelease: includePrerelease,
|
||||
IsDraft: false,
|
||||
PublishedAt: publishedAt,
|
||||
Assets: distribution.Assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: distribution.Latest.VersionText,
|
||||
Release: release,
|
||||
PreferredAsset: SelectPreferredInstallerAsset(distribution.Assets),
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce,
|
||||
PlondsPayload: distribution.Payload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (PlondsRequestException ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS {GetStageName(ex.Stage)} stage failed. {ex.Message}",
|
||||
ex);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: $"PLONDS {GetStageName(ex.Stage)} failed: {ex.Message}",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PLONDS", "PLONDS request failed with an unexpected error.", ex);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: $"PLONDS request failed: {ex.Message}",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LatestDescriptor?> GetLatestDescriptorAsync(
|
||||
string latestUrl,
|
||||
bool allowNoUpdateResponse,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var latestNode = await GetJsonNodeWithRetryAsync(
|
||||
latestUrl,
|
||||
PlondsCheckStage.Latest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ParseLatestDescriptor(latestNode);
|
||||
}
|
||||
catch (PlondsRequestException ex)
|
||||
when (allowNoUpdateResponse &&
|
||||
ex.Stage == PlondsCheckStage.Latest &&
|
||||
ex.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DistributionDescriptor> ResolveDistributionAsync(
|
||||
string endpoint,
|
||||
string apiBasePath,
|
||||
string latestUrl,
|
||||
LatestDescriptor latest,
|
||||
string channelId,
|
||||
string platform,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentLatest = latest;
|
||||
var hasRefreshedLatest = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var distributionUrl = BuildApiUrl(
|
||||
endpoint,
|
||||
apiBasePath,
|
||||
$"distributions/{Uri.EscapeDataString(currentLatest.DistributionId)}");
|
||||
|
||||
try
|
||||
{
|
||||
var distributionNode = await GetJsonNodeWithRetryAsync(
|
||||
distributionUrl,
|
||||
PlondsCheckStage.Distribution,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (TryCreateDistributionDescriptor(
|
||||
distributionNode,
|
||||
currentLatest,
|
||||
channelId,
|
||||
platform,
|
||||
out var descriptor,
|
||||
out var descriptorError))
|
||||
{
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
if (hasRefreshedLatest || descriptorError is null || !IsRecoverableDistributionError(descriptorError))
|
||||
{
|
||||
throw descriptorError ?? new PlondsRequestException(
|
||||
PlondsCheckStage.PayloadParse,
|
||||
"PLONDS distribution payload is incomplete.");
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS distribution '{currentLatest.DistributionId}' is incomplete. Refreshing latest pointer once before failing.");
|
||||
}
|
||||
catch (PlondsRequestException ex) when (!hasRefreshedLatest && IsRecoverableDistributionError(ex))
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS distribution fetch for '{currentLatest.DistributionId}' failed during {GetStageName(ex.Stage)}. Refreshing latest pointer once. Details: {ex.Message}");
|
||||
}
|
||||
|
||||
hasRefreshedLatest = true;
|
||||
currentLatest = await GetLatestDescriptorAsync(
|
||||
latestUrl,
|
||||
allowNoUpdateResponse: false,
|
||||
cancellationToken).ConfigureAwait(false)
|
||||
?? throw new PlondsRequestException(
|
||||
PlondsCheckStage.Latest,
|
||||
"PLONDS latest pointer disappeared while recovering the distribution payload.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetJsonNodeWithRetryAsync(
|
||||
string url,
|
||||
PlondsCheckStage stage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PlondsRequestException? lastError = null;
|
||||
|
||||
for (var attempt = 1; attempt <= MaxTransientRetryAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await GetJsonNodeAsync(url, stage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PlondsRequestException ex) when (attempt < MaxTransientRetryAttempts && ex.IsTransient)
|
||||
{
|
||||
lastError = ex;
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS {GetStageName(stage)} attempt {attempt}/{MaxTransientRetryAttempts} failed. Retrying shortly. Details: {ex.Message}");
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return releaseResult with { ForceMode = false };
|
||||
}
|
||||
|
||||
throw lastError ?? new PlondsRequestException(stage, "PLONDS request failed before a response was returned.");
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetJsonNodeAsync(
|
||||
string url,
|
||||
PlondsCheckStage stage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var token = ResolveToken();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
if (releaseResult.PlondsPayload is not null)
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
return releaseResult with { ForceMode = isForce };
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new PlondsRequestException(stage, "Request timed out.", isTransient: true, innerException: ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new PlondsRequestException(stage, $"Network error: {ex.Message}", isTransient: true, innerException: ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
stage,
|
||||
"HTTP 204: no content.",
|
||||
statusCode: response.StatusCode,
|
||||
isTransient: false);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
stage,
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}",
|
||||
statusCode: response.StatusCode,
|
||||
isTransient: IsTransientStatusCode(response.StatusCode));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("content", out var content))
|
||||
{
|
||||
return content.Clone();
|
||||
}
|
||||
|
||||
return root.Clone();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
stage,
|
||||
$"Invalid JSON response: {ex.Message}",
|
||||
isTransient: IsLikelyIncompleteJson(body),
|
||||
innerException: ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LatestDescriptor ParseLatestDescriptor(JsonElement latestNode)
|
||||
{
|
||||
var latestVersionText = ReadString(latestNode, "version") ?? "-";
|
||||
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
PlondsCheckStage.Latest,
|
||||
$"PLONDS latest distribution version is invalid: '{latestVersionText}'.");
|
||||
}
|
||||
|
||||
var distributionId = ReadString(latestNode, "distributionId");
|
||||
if (string.IsNullOrWhiteSpace(distributionId))
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
PlondsCheckStage.Latest,
|
||||
"PLONDS latest distribution id is missing.");
|
||||
}
|
||||
|
||||
return new LatestDescriptor(distributionId, latestVersionText, latestVersion);
|
||||
}
|
||||
|
||||
private static bool TryCreateDistributionDescriptor(
|
||||
JsonElement distributionNode,
|
||||
LatestDescriptor latest,
|
||||
string channelId,
|
||||
string platform,
|
||||
out DistributionDescriptor descriptor,
|
||||
out PlondsRequestException? error)
|
||||
{
|
||||
descriptor = default!;
|
||||
error = null;
|
||||
|
||||
var assets = ResolveInstallerAssets(distributionNode);
|
||||
var payload = ResolvePlondsPayload(
|
||||
distributionNode,
|
||||
latest.DistributionId,
|
||||
channelId,
|
||||
platform);
|
||||
|
||||
if (assets.Count == 0 && !HasPlondsPayload(payload))
|
||||
{
|
||||
error = new PlondsRequestException(
|
||||
PlondsCheckStage.PayloadParse,
|
||||
"PLONDS distribution response does not expose downloadable update assets.");
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = new DistributionDescriptor(latest, distributionNode, assets, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsRecoverableDistributionError(PlondsRequestException error)
|
||||
{
|
||||
if (error.Stage == PlondsCheckStage.PayloadParse)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return error.Stage == PlondsCheckStage.Distribution &&
|
||||
(error.StatusCode == HttpStatusCode.NotFound ||
|
||||
error.StatusCode == HttpStatusCode.RequestTimeout ||
|
||||
error.StatusCode == HttpStatusCode.TooManyRequests ||
|
||||
error.StatusCode is >= HttpStatusCode.InternalServerError);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
|
||||
{
|
||||
var assets = new List<GitHubReleaseAsset>();
|
||||
|
||||
if (TryGetPropertyIgnoreCase(distributionNode, "installerMirrors", out var installersNode) &&
|
||||
installersNode.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var installerNode in installersNode.EnumerateArray())
|
||||
{
|
||||
if (installerNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(installerNode, "name")
|
||||
?? ReadString(installerNode, "fileName");
|
||||
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];
|
||||
}
|
||||
|
||||
private static bool IsTransientStatusCode(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode == HttpStatusCode.RequestTimeout ||
|
||||
statusCode == HttpStatusCode.TooManyRequests ||
|
||||
statusCode >= HttpStatusCode.InternalServerError;
|
||||
}
|
||||
|
||||
private static bool IsLikelyIncompleteJson(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var trimmed = body.TrimEnd();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var last = trimmed[^1];
|
||||
return last != '}' && last != ']';
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
return attempt switch
|
||||
{
|
||||
1 => TimeSpan.FromMilliseconds(350),
|
||||
2 => TimeSpan.FromMilliseconds(900),
|
||||
_ => TimeSpan.FromMilliseconds(1500)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStageName(PlondsCheckStage stage)
|
||||
{
|
||||
return stage switch
|
||||
{
|
||||
PlondsCheckStage.Metadata => "metadata",
|
||||
PlondsCheckStage.Latest => "latest",
|
||||
PlondsCheckStage.Distribution => "distribution",
|
||||
PlondsCheckStage.PayloadParse => "payload-parse",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private enum PlondsCheckStage
|
||||
{
|
||||
Metadata,
|
||||
Latest,
|
||||
Distribution,
|
||||
PayloadParse
|
||||
}
|
||||
|
||||
private sealed record LatestDescriptor(
|
||||
string DistributionId,
|
||||
string VersionText,
|
||||
Version Version);
|
||||
|
||||
private sealed record DistributionDescriptor(
|
||||
LatestDescriptor Latest,
|
||||
JsonElement DistributionNode,
|
||||
IReadOnlyList<GitHubReleaseAsset> Assets,
|
||||
PlondsUpdatePayload Payload);
|
||||
|
||||
private sealed class PlondsRequestException : Exception
|
||||
{
|
||||
public PlondsRequestException(
|
||||
PlondsCheckStage stage,
|
||||
string message,
|
||||
HttpStatusCode? statusCode = null,
|
||||
bool isTransient = false,
|
||||
Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Stage = stage;
|
||||
StatusCode = statusCode;
|
||||
IsTransient = isTransient;
|
||||
}
|
||||
|
||||
public PlondsCheckStage Stage { get; }
|
||||
|
||||
public HttpStatusCode? StatusCode { get; }
|
||||
|
||||
public bool IsTransient { get; }
|
||||
var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText)
|
||||
? "-"
|
||||
: releaseResult.LatestVersionText;
|
||||
var message = releaseResult.Release is null
|
||||
? "GitHub Release data is unavailable for PLONDS."
|
||||
: $"Release {latestVersion} does not expose platform PLONDS assets yet.";
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: releaseResult.IsUpdateAvailable,
|
||||
CurrentVersionText: releaseResult.CurrentVersionText,
|
||||
LatestVersionText: latestVersion,
|
||||
Release: releaseResult.Release,
|
||||
PreferredAsset: releaseResult.PreferredAsset,
|
||||
ErrorMessage: message,
|
||||
ForceMode: isForce,
|
||||
PlondsPayload: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
@@ -64,6 +65,7 @@ public sealed class UpdateWorkflowService
|
||||
private const string PlondsFileMapName = "plonds-filemap.json";
|
||||
private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
|
||||
private const string PlondsUpdateStateName = "plonds-update.json";
|
||||
private const string PlondsUpdateArchiveName = "plonds-update.zip";
|
||||
|
||||
private static readonly HttpClient PlondsHttpClient = new()
|
||||
{
|
||||
@@ -302,47 +304,65 @@ public sealed class UpdateWorkflowService
|
||||
"filemap-download",
|
||||
cancellationToken);
|
||||
|
||||
IReadOnlyList<PlondsDownloadEntry> downloadEntries;
|
||||
try
|
||||
IReadOnlyList<PlondsDownloadedObjectInfo> objectResults;
|
||||
if (!string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl))
|
||||
{
|
||||
downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
|
||||
progress?.Report(2d / 3d);
|
||||
objectResults = await EnsurePlondsArchiveObjectsAsync(
|
||||
payload,
|
||||
incomingDir,
|
||||
objectsDir,
|
||||
state.UpdateDownloadSource,
|
||||
downloadThreads,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
else
|
||||
{
|
||||
throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (downloadEntries.Count == 0)
|
||||
{
|
||||
throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
|
||||
}
|
||||
|
||||
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++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
continue;
|
||||
downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
var objectInfo = await EnsurePlondsObjectAsync(
|
||||
entry,
|
||||
objectsDir,
|
||||
downloadThreads,
|
||||
cancellationToken);
|
||||
if (downloadEntries.Count == 0)
|
||||
{
|
||||
throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
|
||||
}
|
||||
|
||||
objectResults.Add(objectInfo);
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
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)
|
||||
{
|
||||
if (!objectTargets.Add(entry.ObjectHashHex))
|
||||
{
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
continue;
|
||||
}
|
||||
|
||||
var objectInfo = await EnsurePlondsObjectAsync(
|
||||
entry,
|
||||
objectsDir,
|
||||
downloadThreads,
|
||||
cancellationToken);
|
||||
|
||||
downloadResults.Add(objectInfo);
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
}
|
||||
|
||||
objectResults = downloadResults;
|
||||
}
|
||||
|
||||
var updateState = new PlondsUpdateState(
|
||||
@@ -692,6 +712,91 @@ public sealed class UpdateWorkflowService
|
||||
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)
|
||||
{
|
||||
downloadResult = await _settingsFacade.Update.RedownloadAssetAsync(
|
||||
archiveAsset,
|
||||
archivePath,
|
||||
downloadSource,
|
||||
downloadThreads,
|
||||
archiveProgress,
|
||||
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)
|
||||
{
|
||||
var entries = new List<PlondsDownloadEntry>();
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsReleaseIndexOptions(
|
||||
string ReleaseTag,
|
||||
string Version,
|
||||
string Channel,
|
||||
string PlatformSummariesDirectory,
|
||||
string OutputRoot,
|
||||
string PrivateKeyPath);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record DdssAssetEntry(
|
||||
string AssetId,
|
||||
string FileName,
|
||||
string Sha256,
|
||||
long Size,
|
||||
IReadOnlyList<DdssMirrorEntry> Mirrors);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record DdssManifest(
|
||||
string FormatVersion,
|
||||
string ReleaseTag,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyList<DdssAssetEntry> Assets);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record DdssMirrorEntry(
|
||||
string Type,
|
||||
string Url);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -1,4 +1,4 @@
|
||||
using Plonds.Core.Publishing;
|
||||
using Plonds.Core.Publishing;
|
||||
using Plonds.Core.Security;
|
||||
|
||||
return await PlondsCli.RunAsync(args);
|
||||
@@ -29,6 +29,18 @@ internal static class PlondsCli
|
||||
case "publish":
|
||||
RunPublish(options);
|
||||
return Task.FromResult(0);
|
||||
case "pack-payload":
|
||||
RunPackPayload(options);
|
||||
return Task.FromResult(0);
|
||||
case "build-delta":
|
||||
RunBuildDelta(options);
|
||||
return Task.FromResult(0);
|
||||
case "build-index":
|
||||
RunBuildIndex(options);
|
||||
return Task.FromResult(0);
|
||||
case "build-ddss":
|
||||
RunBuildDdss(options);
|
||||
return Task.FromResult(0);
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
PrintUsage();
|
||||
@@ -101,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)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -142,8 +210,12 @@ internal static class PlondsCli
|
||||
private static void PrintUsage()
|
||||
{
|
||||
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(" 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>]");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user