mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
631dc7795a | ||
|
|
001a42a97f | ||
|
|
8a75bc818a | ||
|
|
8568fdf16b | ||
|
|
d31aa90b9c | ||
|
|
0878bcab5a | ||
|
|
4d5bea0c46 | ||
|
|
8323b8cb61 | ||
|
|
82f1e77393 | ||
|
|
a31ae3cd58 | ||
|
|
3f927c41c8 | ||
|
|
44725d7ff3 | ||
|
|
e623aef350 | ||
|
|
63d5165860 | ||
|
|
6d513096d3 | ||
|
|
f487a32149 | ||
|
|
a553f2f7aa | ||
|
|
f03b74ff32 | ||
|
|
bc1520a5d8 | ||
|
|
46341edbea | ||
|
|
f421f574e1 | ||
|
|
8ea8c684a9 | ||
|
|
b411d91b35 | ||
|
|
a2f0af9031 | ||
|
|
5861d73964 | ||
|
|
64975d5752 | ||
|
|
8c58b1c43e | ||
|
|
e82c5d41fd | ||
|
|
8447910fee | ||
|
|
81e0081721 | ||
|
|
fb21bcd8ec |
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
|
||||||
636
.github/workflows/release.yml
vendored
636
.github/workflows/release.yml
vendored
@@ -30,14 +30,22 @@ jobs:
|
|||||||
informational_version: ${{ steps.version.outputs.informational_version }}
|
informational_version: ${{ steps.version.outputs.informational_version }}
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
|
release_channel: ${{ steps.version.outputs.release_channel }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository metadata
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Get release info
|
- name: Get release info
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
CHECKOUT_REF="${GITHUB_REF}"
|
CHECKOUT_REF="${GITHUB_REF}"
|
||||||
|
IS_PRERELEASE="false"
|
||||||
else
|
else
|
||||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
||||||
@@ -47,19 +55,40 @@ jobs:
|
|||||||
else
|
else
|
||||||
TAG="v${RAW_TAG}"
|
TAG="v${RAW_TAG}"
|
||||||
fi
|
fi
|
||||||
CHECKOUT_REF="${GITHUB_SHA}"
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||||
|
CHECKOUT_REF="refs/tags/${TAG}"
|
||||||
|
else
|
||||||
|
CHECKOUT_REF="${GITHUB_SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
|
||||||
|
IS_PRERELEASE="true"
|
||||||
|
else
|
||||||
|
IS_PRERELEASE="false"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
||||||
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
||||||
VERSION_PARTS+=("0")
|
VERSION_PARTS+=("0")
|
||||||
done
|
done
|
||||||
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
||||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
|
RELEASE_CHANNEL="preview"
|
||||||
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
|
else
|
||||||
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
RELEASE_CHANNEL="stable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -107,9 +136,6 @@ jobs:
|
|||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
|
||||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
|
||||||
|
|
||||||
# AOT publish
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./$launcherPublishDir `
|
-o ./$launcherPublishDir `
|
||||||
@@ -120,27 +146,16 @@ jobs:
|
|||||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||||
-p:EnableCompressionInSingleFile=true `
|
-p:EnableCompressionInSingleFile=true `
|
||||||
-p:DebugType=none `
|
-p:DebugType=none `
|
||||||
-p:DebugSymbols=false
|
-p:DebugSymbols=false `
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "Launcher AOT publish failed"
|
Write-Error "Launcher AOT publish failed"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 鏄剧ず鍙戝竷缁撴灉
|
|
||||||
Write-Host "Launcher published to: $launcherPublishDir"
|
|
||||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
|
||||||
if ($exeFile) {
|
|
||||||
$size = [Math]::Round($exeFile.Length / 1MB, 2)
|
|
||||||
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Warn if unexpected extra files are produced
|
|
||||||
$files = Get-ChildItem -Path $launcherPublishDir -File
|
|
||||||
if ($files.Count -gt 1) {
|
|
||||||
Write-Host "Warning: Expected single file but found $($files.Count) files"
|
|
||||||
$files | ForEach-Object { Write-Host " - $($_.Name)" }
|
|
||||||
}
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Publish Main App
|
- name: Publish Main App
|
||||||
@@ -178,9 +193,6 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Published to: $publishDir"
|
|
||||||
Write-Host "Self-contained: $selfContained"
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Restructure for Launcher
|
- name: Restructure for Launcher
|
||||||
@@ -191,30 +203,18 @@ jobs:
|
|||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
|
|
||||||
Write-Host "Restructuring for Launcher mode..."
|
|
||||||
Write-Host "Version: $version"
|
|
||||||
Write-Host "Publish dir: $publishDir"
|
|
||||||
|
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$newStructure = "publish-launcher/windows-$arch"
|
||||||
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||||
$appPath = Join-Path $newStructure $appDir
|
$appPath = Join-Path $newStructure $appDir
|
||||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||||
|
|
||||||
$launcherSource = $launcherPublishDir
|
if (Test-Path $launcherPublishDir) {
|
||||||
if (Test-Path $launcherSource) {
|
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
Write-Host "Copying Launcher to root..."
|
|
||||||
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
|
|
||||||
} else {
|
|
||||||
Write-Warning "Launcher publish dir not found: $launcherSource"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||||
|
|
||||||
Write-Host "New directory structure:"
|
|
||||||
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
|
|
||||||
|
|
||||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||||
@@ -230,60 +230,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
|
||||||
$suffix = "${{ matrix.suffix }}"
|
$suffix = "${{ matrix.suffix }}"
|
||||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||||
$outputDir = "build-installer"
|
$outputDir = "build-installer"
|
||||||
|
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
||||||
if (-not (Test-Path -Path $publishDir)) {
|
|
||||||
Write-Error "Publish directory not found: $publishDir"
|
|
||||||
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||||
|
|
||||||
if (-not (Test-Path -Path $installerScript)) {
|
|
||||||
Write-Error "Installer script not found: $installerScript"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$isccPath = $null
|
|
||||||
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
|
||||||
if ($isccCommand) {
|
|
||||||
$isccPath = $isccCommand.Source
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidatePaths = @(
|
$candidatePaths = @(
|
||||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
|
||||||
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\bin\ISCC.exe",
|
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
||||||
)
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
|
||||||
|
$isccPath = $candidatePaths | Select-Object -First 1
|
||||||
if (-not $isccPath) {
|
if (-not $isccPath) {
|
||||||
foreach ($candidate in $candidatePaths) {
|
|
||||||
if ($candidate -and (Test-Path -Path $candidate)) {
|
|
||||||
$isccPath = $candidate
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $isccPath) {
|
|
||||||
Write-Host "ISCC.exe was not found in PATH or known locations."
|
|
||||||
Write-Host "Checked locations:"
|
|
||||||
$candidatePaths | ForEach-Object { Write-Host " - $_" }
|
|
||||||
Write-Host "Chocolatey bin listing (if exists):"
|
|
||||||
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
|
|
||||||
Write-Error "Inno Setup compiler not found."
|
Write-Error "Inno Setup compiler not found."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Found Inno Setup at: $isccPath"
|
if (-not (Test-Path $installerScript)) {
|
||||||
|
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
|
||||||
Write-Host "Building installer for Windows $arch with version $version..."
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
$publishDir = (Resolve-Path $publishDir).Path
|
$publishDir = (Resolve-Path $publishDir).Path
|
||||||
$outputDir = (Resolve-Path $outputDir).Path
|
$outputDir = (Resolve-Path $outputDir).Path
|
||||||
@@ -299,8 +270,6 @@ jobs:
|
|||||||
$installerScript
|
$installerScript
|
||||||
)
|
)
|
||||||
|
|
||||||
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
|
|
||||||
|
|
||||||
& $isccPath @compileArgs
|
& $isccPath @compileArgs
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
||||||
@@ -312,102 +281,53 @@ jobs:
|
|||||||
Write-Error "Failed to create installer"
|
Write-Error "Failed to create installer"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Successfully created: $($installerFile.Name)"
|
|
||||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Build Signed FileMap Update Package
|
- name: Package Payload Zip
|
||||||
if: matrix.self_contained == true
|
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$platform = "windows-$arch"
|
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||||
$publishDir = "publish/windows-$arch"
|
if (-not (Test-Path $payloadRoot)) {
|
||||||
$appDir = "app-$version"
|
Write-Error "Payload root not found: $payloadRoot"
|
||||||
$currentAppPath = Join-Path $publishDir $appDir
|
|
||||||
$outputDir = Join-Path "delta-output" $platform
|
|
||||||
$generateScript = "scripts/Generate-DeltaPackage.ps1"
|
|
||||||
$signScript = "scripts/Sign-FileMap.ps1"
|
|
||||||
|
|
||||||
if (-not (Test-Path $currentAppPath)) {
|
|
||||||
Write-Error "Expected app directory not found: $currentAppPath"
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
$stageDir = Join-Path $PWD "payload-stage/windows-$arch"
|
||||||
& $generateScript `
|
$releaseDir = Join-Path $PWD "release-assets"
|
||||||
-PreviousVersion "0.0.0" `
|
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
-CurrentVersion $version `
|
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
|
||||||
-PreviousDir $currentAppPath `
|
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
|
||||||
-CurrentDir $currentAppPath `
|
|
||||||
-OutputDir $outputDir
|
|
||||||
|
|
||||||
$privateKeyPem = @'
|
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||||
${{ secrets.PDC_SIGNING_KEY }}
|
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||||
'@.Trim()
|
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
return
|
||||||
$privateKeyPem = @'
|
}
|
||||||
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
|
||||||
'@.Trim()
|
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
|
||||||
}
|
$destinationDir = Split-Path -Path $destination -Parent
|
||||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
|
||||||
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
|
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
|
||||||
exit 1
|
}
|
||||||
|
|
||||||
|
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
|
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
|
||||||
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
|
if (Test-Path $payloadZip) {
|
||||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
Remove-Item $payloadZip -Force
|
||||||
|
|
||||||
$privateKeyPath = Join-Path $tempDir "private-key.pem"
|
|
||||||
$publicKeyPath = Join-Path $tempDir "public-key.pem"
|
|
||||||
|
|
||||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
|
|
||||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
|
||||||
$rsa.ImportFromPem($privateKeyPem)
|
|
||||||
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
|
|
||||||
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
|
|
||||||
|
|
||||||
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
|
|
||||||
$repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw
|
|
||||||
$repoRsa = [System.Security.Cryptography.RSA]::Create()
|
|
||||||
$repoRsa.ImportFromPem($repoPublicKeyPem)
|
|
||||||
$repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo())
|
|
||||||
$derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo())
|
|
||||||
if ($repoSpki -ne $derivedSpki) {
|
|
||||||
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
|
||||||
& $signScript `
|
|
||||||
-FilesJsonPath (Join-Path $outputDir "files.json") `
|
|
||||||
-PrivateKeyPath $privateKeyPath `
|
|
||||||
-OutputPath (Join-Path $outputDir "files.json.sig")
|
|
||||||
|
|
||||||
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
|
|
||||||
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
|
|
||||||
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload Signed FileMap Update Package
|
- name: Upload Release Assets
|
||||||
if: matrix.self_contained == true
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-update-windows-${{ matrix.arch }}
|
name: release-windows-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json
|
release-assets/files-windows-${{ matrix.arch }}.zip
|
||||||
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig
|
build-installer/*.exe
|
||||||
delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 90
|
|
||||||
- name: Upload Installer
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
|
|
||||||
path: build-installer/*.exe
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
@@ -432,13 +352,10 @@ jobs:
|
|||||||
libx11-6 libxrandr2 libxinerama1 \
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
libxi6 libxcursor1 libxext6 \
|
libxi6 libxcursor1 libxext6 \
|
||||||
libxrender1 libxkbcommon-x11-0 \
|
libxrender1 libxkbcommon-x11-0 \
|
||||||
clang zlib1g-dev
|
clang zlib1g-dev zip rsync
|
||||||
|
|
||||||
# Ubuntu 24.04+ moved several packages to t64 names.
|
|
||||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||||
|
|
||||||
# Prefer modern WebKit package, fallback for older images.
|
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
@@ -460,8 +377,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish Launcher (AOT)
|
- name: Publish Launcher (AOT)
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing Launcher with AOT for Linux x64..."
|
|
||||||
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/launcher-linux-x64 \
|
-o ./publish/launcher-linux-x64 \
|
||||||
@@ -472,15 +387,11 @@ jobs:
|
|||||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
-p:EnableCompressionInSingleFile=true \
|
-p:EnableCompressionInSingleFile=true \
|
||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
if [ $? -ne 0 ]; then
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
echo "Launcher AOT publish failed"
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
exit 1
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Launcher published to: ./publish/launcher-linux-x64"
|
|
||||||
ls -lh ./publish/launcher-linux-x64/
|
|
||||||
|
|
||||||
- name: Publish Main App
|
- name: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
@@ -507,25 +418,15 @@ jobs:
|
|||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
launcherDir="publish/launcher-linux-x64"
|
launcherDir="publish/launcher-linux-x64"
|
||||||
|
|
||||||
echo "Restructuring for Launcher mode..."
|
|
||||||
echo "Version: $version"
|
|
||||||
|
|
||||||
mkdir -p "$publishDir"
|
mkdir -p "$publishDir"
|
||||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||||
|
|
||||||
if [ -d "$launcherDir" ]; then
|
if [ -d "$launcherDir" ]; then
|
||||||
echo "Copying Launcher to root..."
|
|
||||||
cp -r "$launcherDir"/* "$publishDir/"
|
cp -r "$launcherDir"/* "$publishDir/"
|
||||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
else
|
|
||||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
touch "$publishDir/$appDir/.current"
|
touch "$publishDir/$appDir/.current"
|
||||||
|
|
||||||
echo "New directory structure:"
|
|
||||||
find "$publishDir" -maxdepth 2 | head -50
|
|
||||||
|
|
||||||
rm -rf "$launcherDir"
|
rm -rf "$launcherDir"
|
||||||
|
|
||||||
- name: Package as DEB
|
- name: Package as DEB
|
||||||
@@ -538,12 +439,6 @@ jobs:
|
|||||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
||||||
|
|
||||||
if [ ! -d "$source" ]; then
|
|
||||||
echo "Error: Source directory not found: $source"
|
|
||||||
ls -la publish/ || echo "publish directory not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "build-deb/DEBIAN"
|
mkdir -p "build-deb/DEBIAN"
|
||||||
mkdir -p "build-deb/usr/local/bin"
|
mkdir -p "build-deb/usr/local/bin"
|
||||||
mkdir -p "build-deb/usr/share/applications"
|
mkdir -p "build-deb/usr/share/applications"
|
||||||
@@ -552,20 +447,6 @@ jobs:
|
|||||||
|
|
||||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
cp -r "$source"/* "build-deb/usr/local/bin/"
|
||||||
|
|
||||||
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
|
|
||||||
echo "DEB package contains $item_count files"
|
|
||||||
|
|
||||||
if [ "$item_count" -eq 0 ]; then
|
|
||||||
echo "Error: DEB package is empty after copy"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
|
|
||||||
echo "Error: Linux desktop resources are missing"
|
|
||||||
ls -la "LanMountainDesktop/packaging/linux" || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
sed \
|
sed \
|
||||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
||||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||||
@@ -589,9 +470,9 @@ jobs:
|
|||||||
printf '%s\n' "Package: $package_name"
|
printf '%s\n' "Package: $package_name"
|
||||||
printf '%s\n' "Version: $package_version"
|
printf '%s\n' "Version: $package_version"
|
||||||
printf '%s\n' "Architecture: $arch"
|
printf '%s\n' "Architecture: $arch"
|
||||||
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
|
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
|
||||||
printf '%s\n' "Description: LanMountain Desktop Application"
|
printf '%s\n' 'Description: LanMountain Desktop Application'
|
||||||
printf '%s\n' " A desktop application for LanMountain."
|
printf '%s\n' ' A desktop application for LanMountain.'
|
||||||
} > "build-deb/DEBIAN/control"
|
} > "build-deb/DEBIAN/control"
|
||||||
|
|
||||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
|
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
|
||||||
@@ -600,110 +481,49 @@ jobs:
|
|||||||
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||||
chmod 755 "build-deb/DEBIAN/postinst"
|
chmod 755 "build-deb/DEBIAN/postinst"
|
||||||
|
|
||||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
|
||||||
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
|
|
||||||
ls -lh "${package_name}_${package_version}_${arch}.deb"
|
- name: Package Payload Zip
|
||||||
else
|
run: |
|
||||||
echo "Error: Failed to build DEB package"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
|
payload_root="publish/linux-x64/app-$version"
|
||||||
|
release_dir="$PWD/release-assets"
|
||||||
|
stage_dir="$PWD/payload-stage/linux-x64"
|
||||||
|
|
||||||
|
if [ ! -d "$payload_root" ]; then
|
||||||
|
echo "Payload root not found: $payload_root"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build Signed FileMap Update Package
|
rm -rf "$stage_dir"
|
||||||
shell: pwsh
|
mkdir -p "$stage_dir" "$release_dir"
|
||||||
run: |
|
rsync -a \
|
||||||
$ErrorActionPreference = "Stop"
|
--exclude '.current' \
|
||||||
|
--exclude '.partial' \
|
||||||
|
--exclude '.destroy' \
|
||||||
|
"$payload_root/" "$stage_dir/"
|
||||||
|
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
(
|
||||||
$platform = "linux-x64"
|
cd "$stage_dir"
|
||||||
$publishDir = "publish/linux-x64"
|
zip -qr "$release_dir/files-linux-x64.zip" .
|
||||||
$appDir = "app-$version"
|
)
|
||||||
$currentAppPath = Join-Path $publishDir $appDir
|
|
||||||
$outputDir = Join-Path "delta-output" $platform
|
|
||||||
$generateScript = "scripts/Generate-DeltaPackage.ps1"
|
|
||||||
$signScript = "scripts/Sign-FileMap.ps1"
|
|
||||||
|
|
||||||
if (-not (Test-Path $currentAppPath)) {
|
- name: Upload Release Assets
|
||||||
Write-Error "Expected app directory not found: $currentAppPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
|
||||||
& $generateScript `
|
|
||||||
-PreviousVersion "0.0.0" `
|
|
||||||
-CurrentVersion $version `
|
|
||||||
-PreviousDir $currentAppPath `
|
|
||||||
-CurrentDir $currentAppPath `
|
|
||||||
-OutputDir $outputDir
|
|
||||||
|
|
||||||
$privateKeyPem = @'
|
|
||||||
${{ secrets.PDC_SIGNING_KEY }}
|
|
||||||
'@.Trim()
|
|
||||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
|
||||||
$privateKeyPem = @'
|
|
||||||
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
|
||||||
'@.Trim()
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
|
||||||
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
|
|
||||||
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
|
|
||||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
|
||||||
|
|
||||||
$privateKeyPath = Join-Path $tempDir "private-key.pem"
|
|
||||||
$publicKeyPath = Join-Path $tempDir "public-key.pem"
|
|
||||||
|
|
||||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
|
|
||||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
|
||||||
$rsa.ImportFromPem($privateKeyPem)
|
|
||||||
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
|
|
||||||
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
|
|
||||||
|
|
||||||
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
|
|
||||||
$repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw
|
|
||||||
$repoRsa = [System.Security.Cryptography.RSA]::Create()
|
|
||||||
$repoRsa.ImportFromPem($repoPublicKeyPem)
|
|
||||||
$repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo())
|
|
||||||
$derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo())
|
|
||||||
if ($repoSpki -ne $derivedSpki) {
|
|
||||||
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
& $signScript `
|
|
||||||
-FilesJsonPath (Join-Path $outputDir "files.json") `
|
|
||||||
-PrivateKeyPath $privateKeyPath `
|
|
||||||
-OutputPath (Join-Path $outputDir "files.json.sig")
|
|
||||||
|
|
||||||
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
|
|
||||||
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
|
|
||||||
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
|
|
||||||
|
|
||||||
- name: Upload Signed FileMap Update Package
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-update-linux-x64
|
name: release-linux-x64
|
||||||
path: |
|
path: |
|
||||||
delta-output/linux-x64/files-linux-x64.json
|
release-assets/files-linux-x64.zip
|
||||||
delta-output/linux-x64/files-linux-x64.json.sig
|
*.deb
|
||||||
delta-output/linux-x64/update-linux-x64.zip
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 90
|
|
||||||
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: release-linux
|
|
||||||
path: "*.deb"
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
continue-on-error: true
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [x64, arm64]
|
arch: [x64, arm64]
|
||||||
name: Build_macOS_${{ matrix.arch }}
|
name: Build_macOS_${{ matrix.arch }}
|
||||||
@@ -738,8 +558,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish Launcher (AOT)
|
- name: Publish Launcher (AOT)
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
|
||||||
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||||
@@ -750,15 +568,11 @@ jobs:
|
|||||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
-p:EnableCompressionInSingleFile=true \
|
-p:EnableCompressionInSingleFile=true \
|
||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
if [ $? -ne 0 ]; then
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
echo "Launcher AOT publish failed"
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
exit 1
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
|
|
||||||
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
|
|
||||||
|
|
||||||
- name: Publish Main App
|
- name: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
@@ -778,7 +592,22 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Package Payload Zip
|
||||||
|
run: |
|
||||||
|
release_dir="$PWD/release-assets"
|
||||||
|
stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}"
|
||||||
|
payload_root="publish/macos-${{ matrix.arch }}-app"
|
||||||
|
|
||||||
|
rm -rf "$stage_dir"
|
||||||
|
mkdir -p "$stage_dir" "$release_dir"
|
||||||
|
rsync -a "$payload_root/" "$stage_dir/"
|
||||||
|
(
|
||||||
|
cd "$stage_dir"
|
||||||
|
zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" .
|
||||||
|
)
|
||||||
|
|
||||||
- name: Restructure and Package as DMG
|
- name: Restructure and Package as DMG
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
arch="${{ matrix.arch }}"
|
arch="${{ matrix.arch }}"
|
||||||
@@ -787,41 +616,19 @@ jobs:
|
|||||||
launcherDir="publish/launcher-macos-$arch"
|
launcherDir="publish/launcher-macos-$arch"
|
||||||
appSourceDir="publish/macos-$arch-app"
|
appSourceDir="publish/macos-$arch-app"
|
||||||
|
|
||||||
echo "Restructuring for Launcher mode..."
|
|
||||||
echo "Version: $version"
|
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||||
|
|
||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
||||||
|
|
||||||
if [ -d "$appSourceDir" ]; then
|
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||||
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
|
||||||
else
|
|
||||||
echo "Error: Main app source directory not found: $appSourceDir"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$launcherDir" ]; then
|
if [ -d "$launcherDir" ]; then
|
||||||
echo "Copying Launcher to root..."
|
|
||||||
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
||||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
else
|
|
||||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/Resources"
|
mkdir -p "${app_name}.app/Contents/Resources"
|
||||||
|
|
||||||
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
|
|
||||||
echo "App bundle contains $item_count files"
|
|
||||||
|
|
||||||
if [ "$item_count" -eq 0 ]; then
|
|
||||||
echo "Error: App bundle is empty after copy"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
{
|
||||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||||
@@ -845,141 +652,96 @@ jobs:
|
|||||||
|
|
||||||
mkdir -p dmg-temp
|
mkdir -p dmg-temp
|
||||||
cp -r "${app_name}.app" dmg-temp/
|
cp -r "${app_name}.app" dmg-temp/
|
||||||
|
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
|
||||||
|
|
||||||
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
|
- name: Upload Release Assets
|
||||||
echo "Successfully created: ${package_name}.dmg"
|
if: always()
|
||||||
ls -lh "${package_name}.dmg"
|
|
||||||
else
|
|
||||||
echo "Error: Failed to create DMG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf dmg-temp "${app_name}.app"
|
|
||||||
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-macos-${{ matrix.arch }}
|
name: release-macos-${{ matrix.arch }}
|
||||||
path: "*.dmg"
|
path: |
|
||||||
if-no-files-found: error
|
release-assets/files-macos-${{ matrix.arch }}.zip
|
||||||
|
*.dmg
|
||||||
|
if-no-files-found: ignore
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
github-release:
|
github-release:
|
||||||
needs: [ prepare, build-windows, build-linux, build-macos ]
|
needs: [prepare, build-windows, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: release-files
|
||||||
pattern: release-*
|
pattern: release-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: List artifacts structure
|
- name: Normalize release files
|
||||||
run: |
|
run: |
|
||||||
echo "Artifact directory structure:"
|
mkdir -p release-bundle
|
||||||
find artifacts -type f -o -type d | sort
|
|
||||||
echo ""
|
|
||||||
echo "Files found:"
|
|
||||||
find artifacts -type f -exec ls -lh {} \;
|
|
||||||
echo ""
|
|
||||||
echo "Full tree:"
|
|
||||||
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
|
|
||||||
|
|
||||||
- name: Flatten artifacts for release
|
mapfile -t downloaded_files < <(find release-files -type f)
|
||||||
|
if [ "${#downloaded_files[@]}" -eq 0 ]; then
|
||||||
|
echo "No downloaded release artifacts were found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for file in "${downloaded_files[@]}"; do
|
||||||
|
base_name="$(basename "$file")"
|
||||||
|
target_path="release-bundle/$base_name"
|
||||||
|
|
||||||
|
if [ -e "$target_path" ]; then
|
||||||
|
echo "Duplicate release asset name detected: $base_name"
|
||||||
|
echo "Conflicting file: $file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$file" "$target_path"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Validate release files
|
||||||
run: |
|
run: |
|
||||||
echo "Organizing artifacts..."
|
echo "Release files:"
|
||||||
mkdir -p release-files
|
find release-bundle -maxdepth 1 -type f -exec ls -lh {} \;
|
||||||
# Copy installers and packages
|
|
||||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
if [ ! -f release-bundle/files-windows-x64.zip ] || [ ! -f release-bundle/files-windows-x86.zip ] || [ ! -f release-bundle/files-linux-x64.zip ]; then
|
||||||
# Copy signed file-map incremental update assets
|
echo "Required payload zips are missing."
|
||||||
find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
|
exit 1
|
||||||
echo ""
|
fi
|
||||||
echo "Files ready for release:"
|
|
||||||
ls -lh release-files/ || echo "No files found in release-files"
|
file_count=$(find release-bundle -maxdepth 1 -type f | wc -l)
|
||||||
echo ""
|
|
||||||
echo "Total files:"
|
|
||||||
file_count=$(find release-files -type f | wc -l)
|
|
||||||
echo "$file_count"
|
|
||||||
if [ "$file_count" -eq 0 ]; then
|
if [ "$file_count" -eq 0 ]; then
|
||||||
echo "Error: No release files found"
|
echo "No release files were produced."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Incremental Assets to S3 (optional)
|
- name: Create or Update Release
|
||||||
if: ${{ vars.S3_ENDPOINT != '' && vars.S3_BUCKET != '' }}
|
|
||||||
env:
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
S3_REGION: ${{ vars.S3_REGION != '' && vars.S3_REGION || 'cn-nb1' }}
|
|
||||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
S3_OBJECT_PREFIX: lanmountain/distribution-v1
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ -z "${S3_ACCESS_KEY:-}" ] || [ -z "${S3_SECRET_KEY:-}" ]; then
|
|
||||||
echo "S3 credentials are not configured. Skipping optional S3 upload step."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 -m pip install --upgrade awscli
|
|
||||||
|
|
||||||
mkdir -p release-update-assets
|
|
||||||
find release-files -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-update-assets/ \;
|
|
||||||
|
|
||||||
asset_count=$(find release-update-assets -type f | wc -l)
|
|
||||||
if [ "$asset_count" -eq 0 ]; then
|
|
||||||
echo "Error: no incremental update assets found for S3 upload."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY"
|
|
||||||
export AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY"
|
|
||||||
export AWS_DEFAULT_REGION="$S3_REGION"
|
|
||||||
|
|
||||||
version_prefix="${S3_OBJECT_PREFIX}/${{ needs.prepare.outputs.version }}/"
|
|
||||||
latest_prefix="${S3_OBJECT_PREFIX}/latest/"
|
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${version_prefix}" --only-show-errors
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${latest_prefix}" --delete --only-show-errors
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
tag: ${{ needs.prepare.outputs.tag }}
|
tag: ${{ needs.prepare.outputs.tag }}
|
||||||
name: ${{ needs.prepare.outputs.tag }}
|
name: ${{ needs.prepare.outputs.tag }}
|
||||||
commit: ${{ github.sha }}
|
|
||||||
allowUpdates: true
|
allowUpdates: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
|
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||||
artifacts: "release-files/**"
|
artifacts: 'release-bundle/*'
|
||||||
body: |
|
body: |
|
||||||
## Release ${{ needs.prepare.outputs.version }}
|
## Release ${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
### Windows
|
### Installers
|
||||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
|
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
|
||||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
|
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
|
||||||
|
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
|
||||||
|
|
||||||
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
|
### Payload Archives
|
||||||
|
- `files-windows-x64.zip`
|
||||||
Installation: Double-click the .exe file and follow the wizard.
|
- `files-windows-x86.zip`
|
||||||
|
- `files-linux-x64.zip`
|
||||||
### Incremental Update Assets
|
|
||||||
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
|
|
||||||
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
|
|
||||||
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
|
|
||||||
|
|
||||||
Existing users: Launcher will detect platform-matching signed assets and apply update on next startup.
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
|
- macOS assets are best-effort and will not block the release.
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
|
||||||
|
|
||||||
See commits for changes.
|
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
# Checklist
|
# Checklist
|
||||||
|
|
||||||
- [x] `release.yml` produces signed FileMap incremental assets for Windows x64/x86 and Linux x64.
|
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
|
||||||
- [x] `release.yml` no longer depends on `vpk`/VeloPack packaging.
|
- [ ] `release.yml` uploads app payload artifacts for PDCC.
|
||||||
- [x] Launcher update engine applies only signed FileMap payload path.
|
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
|
||||||
- [x] Host update workflow no longer expects `releases.win.json`/`*.nupkg`.
|
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
|
||||||
- [x] Update source setting includes `pdc` and preserves GitHub fallback behavior.
|
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
|
||||||
|
- [ ] Host can persist PDC payload into launcher incoming directory.
|
||||||
|
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
|
||||||
|
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
|
||||||
- [ ] CI run attached proving all release matrix jobs pass.
|
- [ ] CI run attached proving all release matrix jobs pass.
|
||||||
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
||||||
- [ ] Rollback verification report attached.
|
- [ ] Rollback verification report attached.
|
||||||
|
|||||||
@@ -2,29 +2,43 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Replace VeloPack-based incremental packaging with a unified signed FileMap pipeline and prepare for PDC/S3 distribution compatibility, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
||||||
|
|
||||||
## Stage 1 (Completed in this round)
|
## Stage 1 (Completed)
|
||||||
|
|
||||||
- Release workflow outputs signed FileMap incremental assets as the primary path:
|
- Release workflow removed VeloPack-based release packaging.
|
||||||
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
- Signed FileMap path was restored as an interim release mechanism.
|
||||||
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
|
||||||
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
|
||||||
- Launcher and host update runtime remove VeloPack branches and return to signed FileMap apply path.
|
|
||||||
- Host update asset discovery supports platform-scoped names with fallback to legacy generic names.
|
|
||||||
- Optional S3 sync publishes incremental assets in parallel with GitHub Release assets.
|
|
||||||
|
|
||||||
## Stage 2 (In Progress)
|
## Stage 2 (Current Implementation Target)
|
||||||
|
|
||||||
- Introduce PDC-compatible update source (`pdc`) with fallback to GitHub.
|
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
|
||||||
- Add PDC metadata/latest/distribution API consumption abstraction.
|
- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
|
||||||
- Keep Launcher install/apply/rollback state machine unchanged.
|
- Keep GitHub Release installers and metadata as parallel distribution.
|
||||||
- Prepare `phainon.yml`-compatible release metadata for future PDCC integration.
|
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
|
||||||
|
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
|
||||||
|
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
|
||||||
|
|
||||||
|
Expected S3 layout:
|
||||||
|
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
|
||||||
|
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
|
||||||
|
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
|
||||||
|
- `lanmountain/update/installers/<platform>/<arch>/*`
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- `release.yml` no longer contains VeloPack packaging steps.
|
- `release.yml` includes PDCC publish steps and no Velopack steps.
|
||||||
- Windows x64/x86 and Linux x64 release jobs all upload signed FileMap incremental assets.
|
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
|
||||||
- Host auto-update can detect and download platform-matching signed FileMap assets.
|
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
|
||||||
- Launcher `update apply` succeeds with signed FileMap payload and rollback behavior remains unchanged.
|
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
|
||||||
- Optional S3 upload step works when S3 secrets/vars are configured.
|
- Launcher can apply both:
|
||||||
|
- legacy signed `files.json + update.zip`
|
||||||
|
- PDC FileMap object-repo payload.
|
||||||
|
- Rollback semantics remain unchanged.
|
||||||
|
|
||||||
|
## Deprecated Notes
|
||||||
|
|
||||||
|
- The following interim outputs are compatibility-only (not the long-term primary path):
|
||||||
|
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
||||||
|
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
||||||
|
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
- [x] Remove VeloPack packaging from release workflow.
|
- [x] Remove VeloPack packaging from release workflow.
|
||||||
- [x] Promote signed FileMap generation to release primary path.
|
- [x] Keep signed FileMap path as interim compatibility fallback.
|
||||||
- [x] Output platform-scoped incremental assets for Windows x64/x86 and Linux x64.
|
- [x] Remove launcher/runtime Velopack branching.
|
||||||
- [x] Remove launcher/runtime VeloPack branches.
|
- [ ] Add `phainon.yml` for PDCC publish configuration.
|
||||||
- [x] Update host asset discovery to platform-scoped signed FileMap naming.
|
- [ ] Add PDCC installation + publish steps in `release.yml`.
|
||||||
- [x] Add optional S3 sync for incremental assets.
|
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
|
||||||
- [x] Extend update source values with `pdc`.
|
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
|
||||||
- [x] Add PDC check fallback service skeleton in settings domain.
|
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
|
||||||
- [ ] Add full PDC FileMap object-hash download/deploy path.
|
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
|
||||||
- [ ] Add PDCC publish integration and `phainon.yml` CI publishing flow.
|
- [ ] Add PDC payload model into host update check result.
|
||||||
|
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
|
||||||
|
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
|
||||||
|
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.
|
||||||
|
|||||||
@@ -214,14 +214,12 @@ public partial class App : Application
|
|||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
|
|
||||||
// TODO: 从配置读取 GitHub 仓库信息
|
// TODO: 从配置读取 GitHub 仓库信息
|
||||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
|
||||||
|
|
||||||
coordinator = new LauncherFlowCoordinator(
|
coordinator = new LauncherFlowCoordinator(
|
||||||
context,
|
context,
|
||||||
deploymentLocator,
|
deploymentLocator,
|
||||||
new OobeStateService(appRoot),
|
new OobeStateService(appRoot),
|
||||||
new UpdateEngineService(deploymentLocator),
|
new UpdateEngineService(deploymentLocator),
|
||||||
updateCheckService,
|
|
||||||
new PluginInstallerService());
|
new PluginInstallerService());
|
||||||
|
|
||||||
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||||
[JsonSerializable(typeof(SignedFileMap))]
|
[JsonSerializable(typeof(SignedFileMap))]
|
||||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||||
|
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
||||||
|
[JsonSerializable(typeof(PlondsFileMap))]
|
||||||
|
[JsonSerializable(typeof(PlondsComponentEntry))]
|
||||||
|
[JsonSerializable(typeof(PlondsFileEntry))]
|
||||||
|
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
||||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||||
[JsonSerializable(typeof(AppVersionInfo))]
|
[JsonSerializable(typeof(AppVersionInfo))]
|
||||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||||
|
|||||||
@@ -53,3 +53,92 @@ internal sealed class UpdateApplyResult
|
|||||||
|
|
||||||
public string? RolledBackTo { get; init; }
|
public string? RolledBackTo { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class PlondsUpdateMetadata
|
||||||
|
{
|
||||||
|
public string? DistributionId { get; set; }
|
||||||
|
|
||||||
|
public string? Channel { get; set; }
|
||||||
|
|
||||||
|
public string? SubChannel { get; set; }
|
||||||
|
|
||||||
|
public string? FromVersion { get; set; }
|
||||||
|
|
||||||
|
public string? ToVersion { get; set; }
|
||||||
|
|
||||||
|
public string? FileMapPath { get; set; }
|
||||||
|
|
||||||
|
public string? FileMapSignaturePath { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PlondsFileMap
|
||||||
|
{
|
||||||
|
public string? DistributionId { get; set; }
|
||||||
|
|
||||||
|
public string? FromVersion { get; set; }
|
||||||
|
|
||||||
|
public string? ToVersion { get; set; }
|
||||||
|
|
||||||
|
public string? Version { get; set; }
|
||||||
|
|
||||||
|
public string? Platform { get; set; }
|
||||||
|
|
||||||
|
public string? Arch { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||||
|
|
||||||
|
public List<PlondsComponentEntry> Components { get; set; } = [];
|
||||||
|
|
||||||
|
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PlondsComponentEntry
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Version { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||||
|
|
||||||
|
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PlondsFileEntry
|
||||||
|
{
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Action { get; set; } = "replace";
|
||||||
|
|
||||||
|
public string? Url { get; set; }
|
||||||
|
|
||||||
|
public string? ObjectUrl { get; set; }
|
||||||
|
|
||||||
|
public string? ObjectPath { get; set; }
|
||||||
|
|
||||||
|
public string? ObjectKey { get; set; }
|
||||||
|
|
||||||
|
public string? ArchivePath { get; set; }
|
||||||
|
|
||||||
|
public string? Sha256 { get; set; }
|
||||||
|
|
||||||
|
public string? Sha512 { get; set; }
|
||||||
|
|
||||||
|
public string? Sha512Base64 { get; set; }
|
||||||
|
|
||||||
|
public byte[]? Sha512Bytes { get; set; }
|
||||||
|
|
||||||
|
public PlondsHashDescriptor? Hash { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PlondsHashDescriptor
|
||||||
|
{
|
||||||
|
public string? Algorithm { get; set; }
|
||||||
|
|
||||||
|
public string? Value { get; set; }
|
||||||
|
|
||||||
|
public byte[]? Bytes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
private readonly DeploymentLocator _deploymentLocator;
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
private readonly OobeStateService _oobeStateService;
|
private readonly OobeStateService _oobeStateService;
|
||||||
private readonly UpdateEngineService _updateEngine;
|
private readonly UpdateEngineService _updateEngine;
|
||||||
private readonly UpdateCheckService _updateCheckService;
|
|
||||||
private readonly PluginInstallerService _pluginInstallerService;
|
private readonly PluginInstallerService _pluginInstallerService;
|
||||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||||
|
|
||||||
@@ -31,14 +30,12 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
DeploymentLocator deploymentLocator,
|
DeploymentLocator deploymentLocator,
|
||||||
OobeStateService oobeStateService,
|
OobeStateService oobeStateService,
|
||||||
UpdateEngineService updateEngine,
|
UpdateEngineService updateEngine,
|
||||||
UpdateCheckService updateCheckService,
|
|
||||||
PluginInstallerService pluginInstallerService)
|
PluginInstallerService pluginInstallerService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_deploymentLocator = deploymentLocator;
|
_deploymentLocator = deploymentLocator;
|
||||||
_oobeStateService = oobeStateService;
|
_oobeStateService = oobeStateService;
|
||||||
_updateEngine = updateEngine;
|
_updateEngine = updateEngine;
|
||||||
_updateCheckService = updateCheckService;
|
|
||||||
_pluginInstallerService = pluginInstallerService;
|
_pluginInstallerService = pluginInstallerService;
|
||||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1085,6 +1085,12 @@ public partial class App : Application
|
|||||||
// 延迟报告 Ready 直到窗口实际打开并可见
|
// 延迟报告 Ready 直到窗口实际打开并可见
|
||||||
// 使用 Opened 事件确保所有资源已加载完毕
|
// 使用 Opened 事件确保所有资源已加载完毕
|
||||||
mainWindow.Opened += OnMainWindowOpened;
|
mainWindow.Opened += OnMainWindowOpened;
|
||||||
|
|
||||||
|
// 手动显示窗口,因为在 ShutdownMode.OnExplicitShutdown 模式下框架不会自动调用 Show
|
||||||
|
if (!mainWindow.IsVisible)
|
||||||
|
{
|
||||||
|
mainWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
|
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
|
||||||
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
|
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||||
|
|
||||||
public string UpdateDownloadSource { get; set; } = "pdc";
|
public string UpdateDownloadSource { get; set; } = "stcn";
|
||||||
|
|
||||||
public int UpdateDownloadThreads { get; set; } = 4;
|
public int UpdateDownloadThreads { get; set; } = 4;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,20 @@ public sealed record UpdateCheckResult(
|
|||||||
GitHubReleaseInfo? Release,
|
GitHubReleaseInfo? Release,
|
||||||
GitHubReleaseAsset? PreferredAsset,
|
GitHubReleaseAsset? PreferredAsset,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
bool ForceMode = false);
|
bool ForceMode = false,
|
||||||
|
PlondsUpdatePayload? PlondsPayload = null);
|
||||||
|
|
||||||
|
public sealed record PlondsUpdatePayload(
|
||||||
|
string DistributionId,
|
||||||
|
string ChannelId,
|
||||||
|
string SubChannel,
|
||||||
|
string? FileMapJson,
|
||||||
|
string? FileMapSignature,
|
||||||
|
string? FileMapJsonUrl,
|
||||||
|
string? FileMapSignatureUrl,
|
||||||
|
string? UpdateArchiveUrl = null,
|
||||||
|
string? UpdateArchiveSha256 = null,
|
||||||
|
long? UpdateArchiveSizeBytes = null);
|
||||||
|
|
||||||
public sealed record UpdateDownloadResult(
|
public sealed record UpdateDownloadResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
@@ -149,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
var preferredAsset = isUpdateAvailable
|
var preferredAsset = isUpdateAvailable
|
||||||
? SelectPreferredInstallerAsset(release.Assets)
|
? SelectPreferredInstallerAsset(release.Assets)
|
||||||
: null;
|
: null;
|
||||||
|
var plondsPayload = isUpdateAvailable
|
||||||
|
? TryResolvePlondsPayload(release)
|
||||||
|
: null;
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -157,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
LatestVersionText: latestVersionText,
|
LatestVersionText: latestVersionText,
|
||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null);
|
ErrorMessage: null,
|
||||||
|
PlondsPayload: plondsPayload);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -222,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
: release.TagName;
|
: release.TagName;
|
||||||
|
|
||||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||||
|
var plondsPayload = TryResolvePlondsPayload(release);
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -231,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null,
|
ErrorMessage: null,
|
||||||
ForceMode: true);
|
ForceMode: true,
|
||||||
|
PlondsPayload: plondsPayload);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -642,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
|
|
||||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||||
{
|
{
|
||||||
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
if (assets is null || assets.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -654,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
_ => "x64"
|
_ => "x64"
|
||||||
};
|
};
|
||||||
|
|
||||||
var ranked = assets
|
if (OperatingSystem.IsWindows())
|
||||||
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
{
|
||||||
.OrderByDescending(x => x.Score)
|
return assets
|
||||||
.ToList();
|
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.FirstOrDefault(x => x.Score > 0)
|
||||||
|
.Asset;
|
||||||
|
}
|
||||||
|
|
||||||
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
|
if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return assets
|
||||||
|
.Select(asset => (Asset: asset, Score: ScoreLinuxInstallerAsset(asset.Name, architectureToken)))
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.FirstOrDefault(x => x.Score > 0)
|
||||||
|
.Asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
return assets
|
||||||
|
.Select(asset => (Asset: asset, Score: ScoreMacInstallerAsset(asset.Name, architectureToken)))
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.FirstOrDefault(x => x.Score > 0)
|
||||||
|
.Asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||||
|
{
|
||||||
|
if (release.Assets is null || release.Assets.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformSuffix = GetPlatformAssetSuffix();
|
||||||
|
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||||
|
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||||
|
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||||
|
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||||
|
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||||
|
var channelId = release.IsPrerelease
|
||||||
|
? UpdateSettingsValues.ChannelPreview
|
||||||
|
: UpdateSettingsValues.ChannelStable;
|
||||||
|
|
||||||
|
return new PlondsUpdatePayload(
|
||||||
|
DistributionId: distributionId,
|
||||||
|
ChannelId: channelId,
|
||||||
|
SubChannel: platformSuffix,
|
||||||
|
FileMapJson: null,
|
||||||
|
FileMapSignature: null,
|
||||||
|
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||||
|
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||||
|
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||||
|
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||||
|
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||||
|
{
|
||||||
|
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPlatformAssetSuffix()
|
||||||
|
{
|
||||||
|
var os = OperatingSystem.IsWindows()
|
||||||
|
? "windows"
|
||||||
|
: OperatingSystem.IsLinux()
|
||||||
|
? "linux"
|
||||||
|
: OperatingSystem.IsMacOS()
|
||||||
|
? "macos"
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
|
var arch = RuntimeInformation.OSArchitecture switch
|
||||||
|
{
|
||||||
|
Architecture.X86 => "x86",
|
||||||
|
Architecture.Arm => "arm",
|
||||||
|
Architecture.Arm64 => "arm64",
|
||||||
|
_ => "x64"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{os}-{arch}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
|
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
|
||||||
@@ -709,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int ScoreLinuxInstallerAsset(string assetName, string architectureToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assetName))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var score = 0;
|
||||||
|
|
||||||
|
if (assetName.EndsWith(".deb", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 220;
|
||||||
|
}
|
||||||
|
else if (assetName.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 180;
|
||||||
|
}
|
||||||
|
else if (assetName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 160;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetName.Contains("linux", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(architectureToken == "x64" && assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ScoreMacInstallerAsset(string assetName, string architectureToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assetName))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var score = 0;
|
||||||
|
|
||||||
|
if (assetName.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 220;
|
||||||
|
}
|
||||||
|
else if (assetName.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 180;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetName.Contains("mac", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
assetName.Contains("osx", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryParseVersion(string? value, out Version? version)
|
private static bool TryParseVersion(string? value, out Version? version)
|
||||||
{
|
{
|
||||||
version = null;
|
version = null;
|
||||||
|
|||||||
@@ -1,464 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Best-effort PDC client that maps PDC responses to the existing update result model.
|
|
||||||
/// This keeps launcher update contracts stable while allowing a gradual migration.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PdcReleaseUpdateService : IDisposable
|
|
||||||
{
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly bool _ownsHttpClient;
|
|
||||||
|
|
||||||
public PdcReleaseUpdateService(HttpClient? httpClient = null)
|
|
||||||
{
|
|
||||||
if (httpClient is null)
|
|
||||||
{
|
|
||||||
_httpClient = new HttpClient
|
|
||||||
{
|
|
||||||
Timeout = TimeSpan.FromSeconds(20)
|
|
||||||
};
|
|
||||||
_ownsHttpClient = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_httpClient = httpClient;
|
|
||||||
_ownsHttpClient = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_ownsHttpClient)
|
|
||||||
{
|
|
||||||
_httpClient.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
|
||||||
Version currentVersion,
|
|
||||||
bool includePrerelease,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
|
||||||
Version currentVersion,
|
|
||||||
bool includePrerelease,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
|
||||||
Version currentVersion,
|
|
||||||
bool includePrerelease,
|
|
||||||
bool isForce,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
|
||||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
|
||||||
var endpoint = ResolveEndpoint();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(endpoint))
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult(
|
|
||||||
Success: false,
|
|
||||||
IsUpdateAvailable: false,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: "-",
|
|
||||||
Release: null,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: "PDC endpoint is not configured.",
|
|
||||||
ForceMode: isForce);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata");
|
|
||||||
var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var channelId = ResolveChannelId(metadata, includePrerelease);
|
|
||||||
if (string.IsNullOrWhiteSpace(channelId))
|
|
||||||
{
|
|
||||||
channelId = includePrerelease ? "preview" : "stable";
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestUrl = BuildUri(
|
|
||||||
endpoint,
|
|
||||||
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
|
|
||||||
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var latestVersionText = ReadString(latestNode, "version") ?? "-";
|
|
||||||
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult(
|
|
||||||
Success: false,
|
|
||||||
IsUpdateAvailable: false,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: latestVersionText,
|
|
||||||
Release: null,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: "PDC latest distribution version is invalid.",
|
|
||||||
ForceMode: isForce);
|
|
||||||
}
|
|
||||||
|
|
||||||
var distributionId = ReadString(latestNode, "distributionId");
|
|
||||||
if (string.IsNullOrWhiteSpace(distributionId))
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult(
|
|
||||||
Success: false,
|
|
||||||
IsUpdateAvailable: false,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: latestVersionText,
|
|
||||||
Release: null,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: "PDC latest distribution id is missing.",
|
|
||||||
ForceMode: isForce);
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasUpdate = latestVersion > normalizedCurrentVersion;
|
|
||||||
if (!isForce && !hasUpdate)
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult(
|
|
||||||
Success: true,
|
|
||||||
IsUpdateAvailable: false,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: latestVersionText,
|
|
||||||
Release: null,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: null,
|
|
||||||
ForceMode: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
var subChannel = ResolveSubChannel();
|
|
||||||
var distributionUrl = BuildUri(
|
|
||||||
endpoint,
|
|
||||||
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
|
|
||||||
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var assets = ResolveAssets(distributionNode);
|
|
||||||
if (assets.Count == 0)
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult(
|
|
||||||
Success: false,
|
|
||||||
IsUpdateAvailable: false,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: latestVersionText,
|
|
||||||
Release: null,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
|
|
||||||
ForceMode: isForce);
|
|
||||||
}
|
|
||||||
|
|
||||||
var release = new GitHubReleaseInfo(
|
|
||||||
TagName: $"v{latestVersionText}",
|
|
||||||
Name: $"PDC Distribution {latestVersionText}",
|
|
||||||
IsPrerelease: includePrerelease,
|
|
||||||
IsDraft: false,
|
|
||||||
PublishedAt: DateTimeOffset.UtcNow,
|
|
||||||
Assets: assets);
|
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
|
||||||
Success: true,
|
|
||||||
IsUpdateAvailable: true,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: latestVersionText,
|
|
||||||
Release: release,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: null,
|
|
||||||
ForceMode: isForce);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new UpdateCheckResult(
|
|
||||||
Success: false,
|
|
||||||
IsUpdateAvailable: false,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: "-",
|
|
||||||
Release: null,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: $"PDC request failed: {ex.Message}",
|
|
||||||
ForceMode: isForce);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
||||||
var token = ResolveToken();
|
|
||||||
if (!string.IsNullOrWhiteSpace(token))
|
|
||||||
{
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var document = JsonDocument.Parse(body);
|
|
||||||
var root = document.RootElement;
|
|
||||||
if (root.ValueKind == JsonValueKind.Object &&
|
|
||||||
root.TryGetProperty("content", out var content))
|
|
||||||
{
|
|
||||||
return content.Clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
return root.Clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
|
|
||||||
{
|
|
||||||
var assets = new List<GitHubReleaseAsset>();
|
|
||||||
if (distributionNode.ValueKind != JsonValueKind.Object)
|
|
||||||
{
|
|
||||||
return assets;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
|
|
||||||
assetsNode.ValueKind == JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
foreach (var assetNode in assetsNode.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (assetNode.ValueKind != JsonValueKind.Object)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = ReadString(assetNode, "name");
|
|
||||||
var url = ReadString(assetNode, "url") ??
|
|
||||||
ReadString(assetNode, "downloadUrl") ??
|
|
||||||
ReadString(assetNode, "browserDownloadUrl");
|
|
||||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var size = ReadInt64(assetNode, "size") ?? 0L;
|
|
||||||
var sha256 = ReadString(assetNode, "sha256");
|
|
||||||
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assets.Count > 0)
|
|
||||||
{
|
|
||||||
return assets;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field-level fallback for service-side URL projection.
|
|
||||||
var manifestUrl = ReadString(distributionNode, "manifestUrl")
|
|
||||||
?? ReadString(distributionNode, "fileMapUrl");
|
|
||||||
var signatureUrl = ReadString(distributionNode, "signatureUrl")
|
|
||||||
?? ReadString(distributionNode, "fileMapSignatureUrl");
|
|
||||||
var archiveUrl = ReadString(distributionNode, "archiveUrl")
|
|
||||||
?? ReadString(distributionNode, "updateArchiveUrl")
|
|
||||||
?? ReadString(distributionNode, "payloadUrl");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(manifestUrl))
|
|
||||||
{
|
|
||||||
assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(signatureUrl))
|
|
||||||
{
|
|
||||||
assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(archiveUrl))
|
|
||||||
{
|
|
||||||
assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
return assets;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
|
|
||||||
{
|
|
||||||
if (metadataNode.ValueKind != JsonValueKind.Object ||
|
|
||||||
!metadataNode.TryGetProperty("channels", out var channelsNode))
|
|
||||||
{
|
|
||||||
return includePrerelease ? "preview" : "stable";
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty;
|
|
||||||
if (channelsNode.ValueKind != JsonValueKind.Object)
|
|
||||||
{
|
|
||||||
return defaultChannelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
string? matchedPreview = null;
|
|
||||||
string? matchedStable = null;
|
|
||||||
|
|
||||||
foreach (var channel in channelsNode.EnumerateObject())
|
|
||||||
{
|
|
||||||
var name = ReadString(channel.Value, "name") ?? channel.Name;
|
|
||||||
if (string.IsNullOrWhiteSpace(matchedPreview) &&
|
|
||||||
(name.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
name.Contains("beta", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
name.Contains("dev", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
matchedPreview = channel.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(matchedStable) &&
|
|
||||||
(name.Contains("stable", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
name.Contains("release", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
matchedStable = channel.Name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includePrerelease)
|
|
||||||
{
|
|
||||||
return matchedPreview ?? defaultChannelId ?? "preview";
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchedStable ?? defaultChannelId ?? "stable";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveSubChannel()
|
|
||||||
{
|
|
||||||
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL")
|
|
||||||
?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL");
|
|
||||||
if (!string.IsNullOrWhiteSpace(configured))
|
|
||||||
{
|
|
||||||
return configured.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
var os = OperatingSystem.IsWindows()
|
|
||||||
? "windows"
|
|
||||||
: OperatingSystem.IsLinux()
|
|
||||||
? "linux"
|
|
||||||
: OperatingSystem.IsMacOS()
|
|
||||||
? "macos"
|
|
||||||
: "unknown";
|
|
||||||
|
|
||||||
var arch = RuntimeInformation.OSArchitecture switch
|
|
||||||
{
|
|
||||||
Architecture.X86 => "x86",
|
|
||||||
Architecture.Arm => "arm",
|
|
||||||
Architecture.Arm64 => "arm64",
|
|
||||||
_ => "x64"
|
|
||||||
};
|
|
||||||
|
|
||||||
return $"{os}_{arch}_release_folderClassic";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ResolveEndpoint()
|
|
||||||
{
|
|
||||||
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
|
|
||||||
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
|
|
||||||
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ResolveToken()
|
|
||||||
{
|
|
||||||
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
|
|
||||||
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
|
|
||||||
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildUri(string endpoint, string relativePath)
|
|
||||||
{
|
|
||||||
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ReadString(JsonElement node, string propertyName)
|
|
||||||
{
|
|
||||||
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.ValueKind == JsonValueKind.String
|
|
||||||
? value.GetString()
|
|
||||||
: value.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long? ReadInt64(JsonElement node, string propertyName)
|
|
||||||
{
|
|
||||||
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.TryGetInt64(out var number))
|
|
||||||
{
|
|
||||||
return number;
|
|
||||||
}
|
|
||||||
|
|
||||||
var text = value.ToString();
|
|
||||||
return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
|
||||||
? parsed
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryParseVersion(string? value, out Version? version)
|
|
||||||
{
|
|
||||||
version = null;
|
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = value.Trim().TrimStart('v', 'V');
|
|
||||||
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
|
|
||||||
if (separatorIndex > 0)
|
|
||||||
{
|
|
||||||
normalized = normalized[..separatorIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Version.TryParse(normalized, out var parsed))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
version = NormalizeVersion(parsed);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Version NormalizeVersion(Version version)
|
|
||||||
{
|
|
||||||
var major = Math.Max(0, version.Major);
|
|
||||||
var minor = Math.Max(0, version.Minor);
|
|
||||||
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
|
|
||||||
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
|
|
||||||
return revision > 0
|
|
||||||
? new Version(major, minor, build, revision)
|
|
||||||
: new Version(major, minor, build);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatVersionText(Version version)
|
|
||||||
{
|
|
||||||
return version.Revision > 0
|
|
||||||
? version.ToString(4)
|
|
||||||
: version.ToString(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
|
||||||
{
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value[..maxLength];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
80
LanMountainDesktop/Services/PlondsReleaseUpdateService.cs
Normal file
80
LanMountainDesktop/Services/PlondsReleaseUpdateService.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Release-backed PLONDS checker.
|
||||||
|
/// It only succeeds when the latest GitHub Release already exposes platform PLONDS assets.
|
||||||
|
/// If those assets are not ready yet, callers can fall back to the normal GitHub installer flow.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlondsReleaseUpdateService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||||
|
|
||||||
|
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_githubReleaseUpdateService.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
bool isForce,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var releaseResult = isForce
|
||||||
|
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||||
|
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
|
|
||||||
|
if (!releaseResult.Success)
|
||||||
|
{
|
||||||
|
return releaseResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isForce && !releaseResult.IsUpdateAvailable)
|
||||||
|
{
|
||||||
|
return releaseResult with { ForceMode = false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseResult.PlondsPayload is not null)
|
||||||
|
{
|
||||||
|
return releaseResult with { ForceMode = isForce };
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText)
|
||||||
|
? "-"
|
||||||
|
: releaseResult.LatestVersionText;
|
||||||
|
var message = releaseResult.Release is null
|
||||||
|
? "GitHub Release data is unavailable for PLONDS."
|
||||||
|
: $"Release {latestVersion} does not expose platform PLONDS assets yet.";
|
||||||
|
|
||||||
|
return new UpdateCheckResult(
|
||||||
|
Success: false,
|
||||||
|
IsUpdateAvailable: releaseResult.IsUpdateAvailable,
|
||||||
|
CurrentVersionText: releaseResult.CurrentVersionText,
|
||||||
|
LatestVersionText: latestVersion,
|
||||||
|
Release: releaseResult.Release,
|
||||||
|
PreferredAsset: releaseResult.PreferredAsset,
|
||||||
|
ErrorMessage: message,
|
||||||
|
ForceMode: isForce,
|
||||||
|
PlondsPayload: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -356,6 +356,7 @@ public interface IUpdateSettingsService
|
|||||||
void Save(UpdateSettingsState state);
|
void Save(UpdateSettingsState state);
|
||||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||||
|
Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
|
||||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
|
|||||||
@@ -752,7 +752,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
{
|
{
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||||
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
|
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
|
||||||
|
|
||||||
public UpdateSettingsService(ISettingsService settingsService)
|
public UpdateSettingsService(ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
@@ -842,6 +842,18 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
bool isForce = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = isForce
|
||||||
|
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||||
|
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
|
return result.Success ? result.PlondsPayload : null;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
@@ -879,7 +891,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_githubReleaseUpdateService.Dispose();
|
_githubReleaseUpdateService.Dispose();
|
||||||
_pdcReleaseUpdateService.Dispose();
|
_plondsReleaseUpdateService.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||||
@@ -889,20 +901,39 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
|
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
|
||||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var pdcResult = isForce
|
var plondsResult = isForce
|
||||||
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||||
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
|
|
||||||
if (pdcResult.Success)
|
if (plondsResult.Success)
|
||||||
{
|
{
|
||||||
return pdcResult;
|
return plondsResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Warn(
|
AppLogger.Warn(
|
||||||
"UpdateSettings",
|
"UpdateSettings",
|
||||||
$"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}");
|
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||||
|
|
||||||
|
var githubFallbackResult = isForce
|
||||||
|
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||||
|
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
|
|
||||||
|
if (githubFallbackResult.Success)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"UpdateSettings",
|
||||||
|
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"UpdateSettings",
|
||||||
|
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return githubFallbackResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isForce
|
return isForce
|
||||||
@@ -1259,14 +1290,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
|||||||
|
|
||||||
public string GetAppVersionText()
|
public string GetAppVersionText()
|
||||||
{
|
{
|
||||||
// 优先从环境变量读取(Launcher 传递)
|
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||||
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
|
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
|
||||||
if (!string.IsNullOrWhiteSpace(envVersion))
|
if (!string.IsNullOrWhiteSpace(envVersion))
|
||||||
{
|
{
|
||||||
return envVersion;
|
return envVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退:从程序集读取
|
// Fallback: read from application assembly.
|
||||||
var assembly = typeof(App).Assembly;
|
var assembly = typeof(App).Assembly;
|
||||||
var informationalVersion = assembly
|
var informationalVersion = assembly
|
||||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||||
@@ -1306,14 +1337,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
|||||||
|
|
||||||
public string GetAppCodenameText()
|
public string GetAppCodenameText()
|
||||||
{
|
{
|
||||||
// 优先从环境变量读取(Launcher 传递)
|
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||||
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
|
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
|
||||||
if (!string.IsNullOrWhiteSpace(envCodename))
|
if (!string.IsNullOrWhiteSpace(envCodename))
|
||||||
{
|
{
|
||||||
return envCodename;
|
return envCodename;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退:使用默认开发代号
|
// Fallback: use default codename.
|
||||||
return DefaultCodename;
|
return DefaultCodename;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ public static class UpdateSettingsValues
|
|||||||
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
||||||
public const string ModeSilentOnExit = "silent_on_exit";
|
public const string ModeSilentOnExit = "silent_on_exit";
|
||||||
|
|
||||||
public const string DownloadSourcePdc = "pdc";
|
// NOTE: keep constant name for compatibility with existing call sites.
|
||||||
|
public const string DownloadSourcePlonds = "stcn";
|
||||||
|
public const string DownloadSourcePdc = DownloadSourcePlonds;
|
||||||
|
public const string DownloadSourceStcn = DownloadSourcePlonds;
|
||||||
|
public const string LegacyDownloadSourcePlonds = "pdc";
|
||||||
|
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
|
||||||
public const string DownloadSourceGitHub = "github";
|
public const string DownloadSourceGitHub = "github";
|
||||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||||
|
|
||||||
@@ -52,9 +57,14 @@ public static class UpdateSettingsValues
|
|||||||
|
|
||||||
public static string NormalizeDownloadSource(string? value)
|
public static string NormalizeDownloadSource(string? value)
|
||||||
{
|
{
|
||||||
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return DownloadSourcePdc;
|
return DownloadSourceStcn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return DownloadSourcePlonds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -67,8 +77,8 @@ public static class UpdateSettingsValues
|
|||||||
return DownloadSourceGitHub;
|
return DownloadSourceGitHub;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to PDC. Runtime will fallback to GitHub if PDC is unavailable.
|
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
|
||||||
return DownloadSourcePdc;
|
return DownloadSourceStcn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int NormalizeDownloadThreads(int value)
|
public static int NormalizeDownloadThreads(int value)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1965,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.PreferredAsset is null)
|
if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
|
||||||
{
|
{
|
||||||
UpdateStatus = isForce
|
UpdateStatus = isForce
|
||||||
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
|
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
|
||||||
@@ -2050,7 +2050,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
|
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
|
||||||
private async Task RedownloadUpdateAsync()
|
private async Task RedownloadUpdateAsync()
|
||||||
{
|
{
|
||||||
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
|
if (_lastCheckResult is null ||
|
||||||
|
!_lastCheckResult.Success ||
|
||||||
|
!_lastCheckResult.IsUpdateAvailable ||
|
||||||
|
(_lastCheckResult.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(_lastCheckResult)))
|
||||||
{
|
{
|
||||||
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
|
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
|
||||||
return;
|
return;
|
||||||
@@ -2233,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
UpdateDownloadResult downloadResult;
|
UpdateDownloadResult downloadResult;
|
||||||
|
|
||||||
// Prefer delta update if available (smaller download, faster)
|
// Prefer delta update if available (smaller download, faster)
|
||||||
if (result.Release is not null && UpdateWorkflowService.IsDeltaUpdateAvailable(result.Release))
|
if (UpdateWorkflowService.IsDeltaUpdateAvailable(result))
|
||||||
{
|
{
|
||||||
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
|
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
|
||||||
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
|
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
|
||||||
if (!downloadResult.Success)
|
if (!downloadResult.Success && result.PlondsPayload is null)
|
||||||
{
|
{
|
||||||
// Delta download failed, fall back to full installer
|
// Delta download failed, fall back to full installer
|
||||||
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");
|
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<VersionPrefix>0.1.0</VersionPrefix>
|
||||||
|
<PackageVersion>0.1.0</PackageVersion>
|
||||||
|
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>0.1.0.0</FileVersion>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
93
PenguinLogisticsOnlineNetworkDistributionSystem/README.md
Normal file
93
PenguinLogisticsOnlineNetworkDistributionSystem/README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# PLONDS 骨架
|
||||||
|
|
||||||
|
Penguin Logistics Online Network Distribution System(企鹅物流在线网络分发系统),简称 PLONDS,是 LanMountainDesktop 的独立更新分发骨架。
|
||||||
|
|
||||||
|
本目录有意与主应用和启动器隔离,仅包含新的分发协议、一个轻量级的只读 API,以及示例 S3 风格的元数据文件。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
PenguinLogisticsOnlineNetworkDistributionSystem/
|
||||||
|
README.md
|
||||||
|
src/
|
||||||
|
Plonds.Shared/
|
||||||
|
Plonds.Api/
|
||||||
|
sample-data/
|
||||||
|
meta/
|
||||||
|
channels/
|
||||||
|
stable/
|
||||||
|
windows-x64/
|
||||||
|
windows-x86/
|
||||||
|
linux-x64/
|
||||||
|
distributions/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目说明
|
||||||
|
|
||||||
|
- `Plonds.Shared` 提供协议常量和数据模型。
|
||||||
|
- `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。
|
||||||
|
- `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。
|
||||||
|
- `Plonds.Api` 是一个轻量级只读 API,从类似 S3 布局的本地文件夹中读取元数据。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
PLONDS 有意围绕单一的 C# 实现栈构建,以确保协议和发布行为不会在不同语言之间产生偏差。
|
||||||
|
|
||||||
|
```text
|
||||||
|
宿主应用
|
||||||
|
-> 检查更新、下载对象、暂存传入的负载
|
||||||
|
启动器
|
||||||
|
-> 验证签名、应用文件映射、切换部署、回滚
|
||||||
|
|
||||||
|
PLONDS.Api
|
||||||
|
-> 面向客户端的只读元数据投影
|
||||||
|
PLONDS.Tool
|
||||||
|
-> CI/发布命令界面
|
||||||
|
PLONDS.Core
|
||||||
|
-> 哈希/差异/对象仓库/签名/发布实现
|
||||||
|
PLONDS.Shared
|
||||||
|
-> 协议常量和 DTO
|
||||||
|
```
|
||||||
|
|
||||||
|
## v1 规则
|
||||||
|
|
||||||
|
- 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。
|
||||||
|
- `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。
|
||||||
|
- 宿主应用保留下载职责。
|
||||||
|
- 启动器保留应用、原子切换、快照和回滚职责。
|
||||||
|
|
||||||
|
## 存储布局
|
||||||
|
|
||||||
|
第一版本保持固定的对象根目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lanmountain/update/
|
||||||
|
repo/sha256/<前缀>/<哈希>
|
||||||
|
meta/channels/<频道>/<平台>/latest.json
|
||||||
|
meta/distributions/<分发ID>.json
|
||||||
|
installers/<平台>/<版本>/...
|
||||||
|
```
|
||||||
|
|
||||||
|
已规划但 v1 中未启用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希>
|
||||||
|
lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 公共接口
|
||||||
|
|
||||||
|
API 基础路径为 `/api/plonds/v1`。
|
||||||
|
|
||||||
|
- `GET /healthz` - 健康检查
|
||||||
|
- `GET /api/plonds/v1/metadata` - 获取元数据目录
|
||||||
|
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本
|
||||||
|
- `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/Plonds.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
默认情况下,API 从 `sample-data` 读取元数据。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "linux-x64",
|
||||||
|
"distributionId": "plonds-0.8.5.2-linux-x64",
|
||||||
|
"version": "0.8.5.2",
|
||||||
|
"publishedAt": "2026-04-20T00:00:00Z",
|
||||||
|
"distributionPath": "meta/distributions/plonds-0.8.5.2-linux-x64.json",
|
||||||
|
"fileMapPath": "meta/distributions/plonds-0.8.5.2-linux-x64.json"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x64",
|
||||||
|
"distributionId": "plonds-0.8.5.2-windows-x64",
|
||||||
|
"version": "0.8.5.2",
|
||||||
|
"publishedAt": "2026-04-20T00:00:00Z",
|
||||||
|
"distributionPath": "meta/distributions/plonds-0.8.5.2-windows-x64.json",
|
||||||
|
"fileMapPath": "meta/distributions/plonds-0.8.5.2-windows-x64.json"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x86",
|
||||||
|
"distributionId": "plonds-0.8.5.2-windows-x86",
|
||||||
|
"version": "0.8.5.2",
|
||||||
|
"publishedAt": "2026-04-20T00:00:00Z",
|
||||||
|
"distributionPath": "meta/distributions/plonds-0.8.5.2-windows-x86.json",
|
||||||
|
"fileMapPath": "meta/distributions/plonds-0.8.5.2-windows-x86.json"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"distributionId": "plonds-0.8.5.2-linux-x64",
|
||||||
|
"version": "0.8.5.2",
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "linux-x64",
|
||||||
|
"publishedAt": "2026-04-20T00:00:00Z",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "app",
|
||||||
|
"root": "app-0.8.5.2/",
|
||||||
|
"mode": "file-object",
|
||||||
|
"metadata": {
|
||||||
|
"allowDiffUpdate": "true"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "LanMountainDesktop",
|
||||||
|
"op": "replace",
|
||||||
|
"contentHash": "sha256-placeholder-lanmountain-linux",
|
||||||
|
"size": 2048000,
|
||||||
|
"mode": "file-object",
|
||||||
|
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-linux"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "installers",
|
||||||
|
"root": "installers/linux-x64/",
|
||||||
|
"mode": "file-object",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "LanMountainDesktop-0.8.5.2-linux-x64.deb",
|
||||||
|
"op": "add",
|
||||||
|
"contentHash": "sha256-placeholder-linux-x64-installer",
|
||||||
|
"size": 3096576,
|
||||||
|
"mode": "file-object",
|
||||||
|
"objectKey": "installers/linux-x64/LanMountainDesktop-0.8.5.2-linux-x64.deb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installerMirrors": [
|
||||||
|
{
|
||||||
|
"platform": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"url": "https://downloads.example.invalid/lanmountain/linux-x64/LanMountainDesktop-0.8.5.2-linux-x64.deb",
|
||||||
|
"fileName": "LanMountainDesktop-0.8.5.2-linux-x64.deb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"file-object",
|
||||||
|
"compressed-object",
|
||||||
|
"binary-patch"
|
||||||
|
],
|
||||||
|
"signatures": [
|
||||||
|
{
|
||||||
|
"algorithm": "rsa-sha256",
|
||||||
|
"keyId": "lanmountain-main",
|
||||||
|
"signature": "placeholder-signature"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"notes": "sample distribution for PLONDS skeleton"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"distributionId": "plonds-0.8.5.2-windows-x64",
|
||||||
|
"version": "0.8.5.2",
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x64",
|
||||||
|
"publishedAt": "2026-04-20T00:00:00Z",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "app",
|
||||||
|
"root": "app-0.8.5.2/",
|
||||||
|
"mode": "file-object",
|
||||||
|
"metadata": {
|
||||||
|
"allowDiffUpdate": "true"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "LanMountainDesktop.exe",
|
||||||
|
"op": "replace",
|
||||||
|
"contentHash": "sha256-placeholder-lanmountain-exe",
|
||||||
|
"size": 1024000,
|
||||||
|
"mode": "file-object",
|
||||||
|
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "installers",
|
||||||
|
"root": "installers/windows-x64/",
|
||||||
|
"mode": "file-object",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "LanMountainDesktop-Setup-0.8.5.2-x64.exe",
|
||||||
|
"op": "add",
|
||||||
|
"contentHash": "sha256-placeholder-windows-x64-installer",
|
||||||
|
"size": 2048000,
|
||||||
|
"mode": "file-object",
|
||||||
|
"objectKey": "installers/windows-x64/LanMountainDesktop-Setup-0.8.5.2-x64.exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installerMirrors": [
|
||||||
|
{
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"url": "https://downloads.example.invalid/lanmountain/windows-x64/LanMountainDesktop-Setup-0.8.5.2-x64.exe",
|
||||||
|
"fileName": "LanMountainDesktop-Setup-0.8.5.2-x64.exe"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"file-object",
|
||||||
|
"compressed-object",
|
||||||
|
"binary-patch"
|
||||||
|
],
|
||||||
|
"signatures": [
|
||||||
|
{
|
||||||
|
"algorithm": "rsa-sha256",
|
||||||
|
"keyId": "lanmountain-main",
|
||||||
|
"signature": "placeholder-signature"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"notes": "sample distribution for PLONDS skeleton"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"distributionId": "plonds-0.8.5.2-windows-x86",
|
||||||
|
"version": "0.8.5.2",
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x86",
|
||||||
|
"publishedAt": "2026-04-20T00:00:00Z",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "app",
|
||||||
|
"root": "app-0.8.5.2/",
|
||||||
|
"mode": "file-object",
|
||||||
|
"metadata": {
|
||||||
|
"allowDiffUpdate": "true"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "LanMountainDesktop.exe",
|
||||||
|
"op": "replace",
|
||||||
|
"contentHash": "sha256-placeholder-lanmountain-exe-x86",
|
||||||
|
"size": 983040,
|
||||||
|
"mode": "file-object",
|
||||||
|
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-exe-x86"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "installers",
|
||||||
|
"root": "installers/windows-x86/",
|
||||||
|
"mode": "file-object",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "LanMountainDesktop-Setup-0.8.5.2-x86.exe",
|
||||||
|
"op": "add",
|
||||||
|
"contentHash": "sha256-placeholder-windows-x86-installer",
|
||||||
|
"size": 1982464,
|
||||||
|
"mode": "file-object",
|
||||||
|
"objectKey": "installers/windows-x86/LanMountainDesktop-Setup-0.8.5.2-x86.exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installerMirrors": [
|
||||||
|
{
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x86",
|
||||||
|
"url": "https://downloads.example.invalid/lanmountain/windows-x86/LanMountainDesktop-Setup-0.8.5.2-x86.exe",
|
||||||
|
"fileName": "LanMountainDesktop-Setup-0.8.5.2-x86.exe"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"file-object",
|
||||||
|
"compressed-object",
|
||||||
|
"binary-patch"
|
||||||
|
],
|
||||||
|
"signatures": [
|
||||||
|
{
|
||||||
|
"algorithm": "rsa-sha256",
|
||||||
|
"keyId": "lanmountain-main",
|
||||||
|
"signature": "placeholder-signature"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"notes": "sample distribution for PLONDS skeleton"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Plonds.Api.Configuration;
|
||||||
|
|
||||||
|
public sealed class PlondsApiOptions
|
||||||
|
{
|
||||||
|
public string StorageRoot { get; set; } = Plonds.Shared.PlondsConstants.DefaultStorageRoot;
|
||||||
|
|
||||||
|
public string MetaRoot { get; set; } = Plonds.Shared.PlondsConstants.DefaultMetaRoot;
|
||||||
|
|
||||||
|
public string ApiBasePath { get; set; } = Plonds.Shared.PlondsConstants.DefaultApiBasePath;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Plonds.Api</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Plonds.Api.Configuration;
|
||||||
|
using Plonds.Api.Services;
|
||||||
|
using Plonds.Shared;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.Configure<PlondsApiOptions>(builder.Configuration.GetSection("Plonds"));
|
||||||
|
builder.Services.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<PlondsApiOptions>>().Value;
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<IPlondsManifestStore>(sp =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<PlondsApiOptions>();
|
||||||
|
return new FileSystemPlondsManifestStore(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
var apiBasePath = app.Configuration["Plonds:ApiBasePath"];
|
||||||
|
if (string.IsNullOrWhiteSpace(apiBasePath))
|
||||||
|
{
|
||||||
|
apiBasePath = PlondsConstants.DefaultApiBasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiBasePath.StartsWith('/'))
|
||||||
|
{
|
||||||
|
apiBasePath = "/" + apiBasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapGet("/healthz", () => Results.Ok(new { status = "ok", protocol = PlondsConstants.ProtocolName, version = PlondsConstants.ProtocolVersion }));
|
||||||
|
|
||||||
|
app.MapGet($"{apiBasePath}/metadata", async (IPlondsManifestStore store, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var catalog = await store.GetCatalogAsync(cancellationToken);
|
||||||
|
return Results.Ok(catalog);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet($"{apiBasePath}/channels/{{channel}}/{{platform}}/latest", async (
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
string? currentVersion,
|
||||||
|
IPlondsManifestStore store,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var latest = await store.GetLatestAsync(channel, platform, cancellationToken);
|
||||||
|
if (latest is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new
|
||||||
|
{
|
||||||
|
error = "latest_pointer_not_found",
|
||||||
|
channel,
|
||||||
|
platform
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(currentVersion) &&
|
||||||
|
Version.TryParse(currentVersion, out var current) &&
|
||||||
|
Version.TryParse(latest.Version, out var target) &&
|
||||||
|
target <= current)
|
||||||
|
{
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(latest);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet($"{apiBasePath}/distributions/{{distributionId}}", async (string distributionId, IPlondsManifestStore store, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var distribution = await store.GetDistributionAsync(distributionId, cancellationToken);
|
||||||
|
if (distribution is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new
|
||||||
|
{
|
||||||
|
error = "distribution_not_found",
|
||||||
|
distributionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(distribution);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Plonds.Api.Configuration;
|
||||||
|
using Plonds.Shared;
|
||||||
|
using Plonds.Shared.Models;
|
||||||
|
|
||||||
|
namespace Plonds.Api.Services;
|
||||||
|
|
||||||
|
public sealed class FileSystemPlondsManifestStore : IPlondsManifestStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly PlondsApiOptions _options;
|
||||||
|
private readonly string _storageRootFullPath;
|
||||||
|
private readonly string _metaRootFullPath;
|
||||||
|
|
||||||
|
public FileSystemPlondsManifestStore(PlondsApiOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_storageRootFullPath = ResolveRootPath(options.StorageRoot);
|
||||||
|
_metaRootFullPath = Path.Combine(_storageRootFullPath, options.MetaRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlondsMetadataCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
|
||||||
|
var channelsRoot = Path.Combine(_metaRootFullPath, "channels");
|
||||||
|
var latest = new List<PlondsChannelPointer>();
|
||||||
|
if (Directory.Exists(channelsRoot))
|
||||||
|
{
|
||||||
|
foreach (var latestPath in Directory.EnumerateFiles(channelsRoot, "latest.json", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var pointer = ReadLatestPointer(latestPath);
|
||||||
|
if (pointer is not null)
|
||||||
|
{
|
||||||
|
latest.Add(pointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = new PlondsMetadataCatalog(
|
||||||
|
ProtocolName: PlondsConstants.ProtocolName,
|
||||||
|
ProtocolVersion: PlondsConstants.ProtocolVersion,
|
||||||
|
StorageRoot: _storageRootFullPath,
|
||||||
|
MetaRoot: _metaRootFullPath,
|
||||||
|
Latest: latest.OrderBy(x => x.Channel, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(x => x.Platform, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray(),
|
||||||
|
Metadata: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["apiBasePath"] = PlondsConstants.DefaultApiBasePath
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.FromResult(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlondsChannelPointer?> GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
return Task.FromResult(ReadLatestPointer(GetLatestPath(channel, platform)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlondsDistributionInfo?> GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
|
||||||
|
var path = GetDistributionPath(distributionId);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return Task.FromResult<PlondsDistributionInfo?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var distribution = JsonSerializer.Deserialize<PlondsDistributionInfo>(json, JsonOptions);
|
||||||
|
return Task.FromResult(distribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlondsChannelPointer? ReadLatestPointer(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var pointer = JsonSerializer.Deserialize<PlondsChannelPointer>(json, JsonOptions);
|
||||||
|
return pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetLatestPath(string channel, string platform)
|
||||||
|
{
|
||||||
|
return Path.Combine(_metaRootFullPath, "channels", channel, platform, "latest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetDistributionPath(string distributionId)
|
||||||
|
{
|
||||||
|
return Path.Combine(_metaRootFullPath, "distributions", $"{distributionId}.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRootPath(string root)
|
||||||
|
{
|
||||||
|
if (Path.IsPathRooted(root))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
AddCandidateChain(candidates, Directory.GetCurrentDirectory(), root);
|
||||||
|
AddCandidateChain(candidates, AppContext.BaseDirectory, root);
|
||||||
|
|
||||||
|
foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (Directory.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.FirstOrDefault() ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCandidateChain(ICollection<string> candidates, string? startDirectory, string relativeRoot)
|
||||||
|
{
|
||||||
|
var current = string.IsNullOrWhiteSpace(startDirectory)
|
||||||
|
? null
|
||||||
|
: Path.GetFullPath(startDirectory);
|
||||||
|
|
||||||
|
while (!string.IsNullOrWhiteSpace(current))
|
||||||
|
{
|
||||||
|
candidates.Add(Path.GetFullPath(Path.Combine(current, relativeRoot)));
|
||||||
|
current = Directory.GetParent(current)?.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Plonds.Shared.Models;
|
||||||
|
|
||||||
|
namespace Plonds.Api.Services;
|
||||||
|
|
||||||
|
public interface IPlondsManifestStore
|
||||||
|
{
|
||||||
|
Task<PlondsMetadataCatalog> GetCatalogAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PlondsChannelPointer?> GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PlondsDistributionInfo?> GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Plonds": {
|
||||||
|
"StorageRoot": "sample-data",
|
||||||
|
"MetaRoot": "meta",
|
||||||
|
"ApiBasePath": "/api/plonds/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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,13 @@
|
|||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed record PlatformPublishResult(
|
||||||
|
string Platform,
|
||||||
|
string DistributionId,
|
||||||
|
string CurrentAppDirectory,
|
||||||
|
string? PreviousDirectory,
|
||||||
|
string PreviousVersion,
|
||||||
|
string FileMapPath,
|
||||||
|
string SignaturePath,
|
||||||
|
string DistributionPath,
|
||||||
|
string LatestPath,
|
||||||
|
IReadOnlyList<string> InstallerFiles);
|
||||||
@@ -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,23 @@
|
|||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed record PlondsGenerateOptions(
|
||||||
|
string CurrentVersion,
|
||||||
|
string CurrentDirectory,
|
||||||
|
string Platform,
|
||||||
|
string OutputRoot,
|
||||||
|
string PreviousVersion = "0.0.0",
|
||||||
|
string? PreviousDirectory = null,
|
||||||
|
string Channel = "stable",
|
||||||
|
string? DistributionId = null,
|
||||||
|
string? RepoBaseUrl = null,
|
||||||
|
string? FileMapUrl = null,
|
||||||
|
string? FileMapSignatureUrl = null,
|
||||||
|
string? InstallerDirectory = null,
|
||||||
|
string? InstallerBaseUrl = null,
|
||||||
|
string IncrementalStrategy = "release-payload",
|
||||||
|
string? BaselineVersion = null,
|
||||||
|
string? BaselineRef = null,
|
||||||
|
string? SourceCommit = null,
|
||||||
|
bool IsFullPayloadRelease = false,
|
||||||
|
string? CommitRangeStart = null,
|
||||||
|
string? CommitRangeEnd = null);
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed class PlondsGenerator
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public PlatformPublishResult Generate(PlondsGenerateOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var currentDirectory = Path.GetFullPath(options.CurrentDirectory);
|
||||||
|
if (!Directory.Exists(currentDirectory))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException($"Current directory not found: {currentDirectory}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousDirectory = string.IsNullOrWhiteSpace(options.PreviousDirectory)
|
||||||
|
? null
|
||||||
|
: Path.GetFullPath(options.PreviousDirectory);
|
||||||
|
|
||||||
|
var distributionId = string.IsNullOrWhiteSpace(options.DistributionId)
|
||||||
|
? $"plonds-{options.CurrentVersion}-{options.Platform}"
|
||||||
|
: options.DistributionId.Trim();
|
||||||
|
|
||||||
|
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||||
|
var repoRoot = Path.Combine(outputRoot, "repo", "sha256");
|
||||||
|
var manifestsRoot = Path.Combine(outputRoot, "manifests", distributionId);
|
||||||
|
var metaDistributionRoot = Path.Combine(outputRoot, "meta", "distributions");
|
||||||
|
var metaChannelRoot = Path.Combine(outputRoot, "meta", "channels", options.Channel, options.Platform);
|
||||||
|
var installerMirrorRoot = Path.Combine(outputRoot, "installers", options.Platform, options.CurrentVersion);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(repoRoot);
|
||||||
|
Directory.CreateDirectory(manifestsRoot);
|
||||||
|
Directory.CreateDirectory(metaDistributionRoot);
|
||||||
|
Directory.CreateDirectory(metaChannelRoot);
|
||||||
|
|
||||||
|
var previousManifest = options.IsFullPayloadRelease
|
||||||
|
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
: ScanDirectory(previousDirectory);
|
||||||
|
var currentManifest = ScanDirectory(currentDirectory);
|
||||||
|
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
|
||||||
|
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
|
||||||
|
var publishedAt = DateTimeOffset.UtcNow;
|
||||||
|
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
|
||||||
|
? options.PreviousVersion
|
||||||
|
: options.BaselineVersion;
|
||||||
|
|
||||||
|
var fileMap = new FileMapDocument(
|
||||||
|
FormatVersion: "1.0",
|
||||||
|
DistributionId: distributionId,
|
||||||
|
FromVersion: options.PreviousVersion,
|
||||||
|
ToVersion: options.CurrentVersion,
|
||||||
|
Platform: options.Platform,
|
||||||
|
Channel: options.Channel,
|
||||||
|
PublishedAt: publishedAt,
|
||||||
|
Capabilities: ["file-object"],
|
||||||
|
Components:
|
||||||
|
[
|
||||||
|
new ComponentDocument(
|
||||||
|
Id: "app",
|
||||||
|
Root: "/",
|
||||||
|
Mode: "file-object",
|
||||||
|
Files: fileEntries,
|
||||||
|
Metadata: new Dictionary<string, string> { ["component"] = "app" })
|
||||||
|
],
|
||||||
|
Metadata: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["protocol"] = "PLONDS",
|
||||||
|
["mode"] = "file-object",
|
||||||
|
["baselineVersion"] = baselineVersion,
|
||||||
|
["incrementalStrategy"] = options.IncrementalStrategy,
|
||||||
|
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
|
||||||
|
["sourceCommit"] = options.SourceCommit ?? string.Empty,
|
||||||
|
["baselineRef"] = options.BaselineRef ?? string.Empty,
|
||||||
|
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
|
||||||
|
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
|
||||||
|
});
|
||||||
|
|
||||||
|
var distribution = new DistributionDocument(
|
||||||
|
DistributionId: distributionId,
|
||||||
|
Version: options.CurrentVersion,
|
||||||
|
Channel: options.Channel,
|
||||||
|
Platform: options.Platform,
|
||||||
|
PublishedAt: publishedAt,
|
||||||
|
FileMapUrl: options.FileMapUrl,
|
||||||
|
FileMapSignatureUrl: options.FileMapSignatureUrl,
|
||||||
|
Components: fileMap.Components,
|
||||||
|
InstallerMirrors: installerMirrors,
|
||||||
|
Capabilities: ["file-object"],
|
||||||
|
Metadata: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["protocol"] = "PLONDS",
|
||||||
|
["baselineVersion"] = baselineVersion,
|
||||||
|
["incrementalStrategy"] = options.IncrementalStrategy,
|
||||||
|
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
|
||||||
|
["sourceCommit"] = options.SourceCommit ?? string.Empty,
|
||||||
|
["baselineRef"] = options.BaselineRef ?? string.Empty,
|
||||||
|
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
|
||||||
|
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
|
||||||
|
});
|
||||||
|
|
||||||
|
var latest = new LatestPointerDocument(
|
||||||
|
DistributionId: distributionId,
|
||||||
|
Version: options.CurrentVersion,
|
||||||
|
Channel: options.Channel,
|
||||||
|
Platform: options.Platform,
|
||||||
|
PublishedAt: publishedAt);
|
||||||
|
|
||||||
|
var fileMapPath = Path.Combine(manifestsRoot, "plonds-filemap.json");
|
||||||
|
var distributionPath = Path.Combine(metaDistributionRoot, distributionId + ".json");
|
||||||
|
var latestPath = Path.Combine(metaChannelRoot, "latest.json");
|
||||||
|
|
||||||
|
WriteJson(fileMapPath, fileMap);
|
||||||
|
WriteJson(distributionPath, distribution);
|
||||||
|
WriteJson(latestPath, latest);
|
||||||
|
|
||||||
|
return new PlatformPublishResult(
|
||||||
|
options.Platform,
|
||||||
|
distributionId,
|
||||||
|
currentDirectory,
|
||||||
|
previousDirectory,
|
||||||
|
options.PreviousVersion,
|
||||||
|
fileMapPath,
|
||||||
|
fileMapPath + ".sig",
|
||||||
|
distributionPath,
|
||||||
|
latestPath,
|
||||||
|
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
|
||||||
|
{
|
||||||
|
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||||||
|
{
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedRoot = Path.GetFullPath(root);
|
||||||
|
foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(resolvedRoot, filePath).Replace('\\', '/');
|
||||||
|
if (ShouldIgnore(relativePath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
manifest[relativePath] = new FileFingerprint(relativePath, filePath, ComputeSha256(filePath), fileInfo.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FileEntryDocument> BuildFileEntries(
|
||||||
|
Dictionary<string, FileFingerprint> previousManifest,
|
||||||
|
Dictionary<string, FileFingerprint> currentManifest,
|
||||||
|
string repoRoot,
|
||||||
|
string? repoBaseUrl)
|
||||||
|
{
|
||||||
|
var entries = new List<FileEntryDocument>();
|
||||||
|
|
||||||
|
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var current = currentManifest[path];
|
||||||
|
if (previousManifest.TryGetValue(path, out var previous) &&
|
||||||
|
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
entries.Add(new FileEntryDocument(
|
||||||
|
Path: path,
|
||||||
|
Action: "reuse",
|
||||||
|
Sha256: current.Sha256,
|
||||||
|
Size: current.Size,
|
||||||
|
Mode: "file-object",
|
||||||
|
ObjectKey: null,
|
||||||
|
ObjectUrl: null,
|
||||||
|
Metadata: null));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
||||||
|
var objectKey = CopyContentObject(current.FullPath, repoRoot, current.Sha256);
|
||||||
|
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
||||||
|
? null
|
||||||
|
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
|
||||||
|
|
||||||
|
entries.Add(new FileEntryDocument(
|
||||||
|
Path: path,
|
||||||
|
Action: action,
|
||||||
|
Sha256: current.Sha256,
|
||||||
|
Size: current.Size,
|
||||||
|
Mode: "file-object",
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
ObjectUrl: objectUrl,
|
||||||
|
Metadata: new Dictionary<string, string> { ["mode"] = "file-object" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!currentManifest.ContainsKey(path))
|
||||||
|
{
|
||||||
|
entries.Add(new FileEntryDocument(
|
||||||
|
Path: path,
|
||||||
|
Action: "delete",
|
||||||
|
Sha256: string.Empty,
|
||||||
|
Size: 0,
|
||||||
|
Mode: "file-object",
|
||||||
|
ObjectKey: null,
|
||||||
|
ObjectUrl: null,
|
||||||
|
Metadata: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InstallerMirrorDocument> BuildInstallerMirrors(
|
||||||
|
string platform,
|
||||||
|
string installerMirrorRoot,
|
||||||
|
string? installerSourceDirectory,
|
||||||
|
string? installerBaseUrl)
|
||||||
|
{
|
||||||
|
var result = new List<InstallerMirrorDocument>();
|
||||||
|
if (string.IsNullOrWhiteSpace(installerSourceDirectory) || !Directory.Exists(installerSourceDirectory))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(installerMirrorRoot);
|
||||||
|
foreach (var sourceFile in Directory.EnumerateFiles(installerSourceDirectory))
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(sourceFile);
|
||||||
|
var destinationPath = Path.Combine(installerMirrorRoot, fileName);
|
||||||
|
File.Copy(sourceFile, destinationPath, overwrite: true);
|
||||||
|
|
||||||
|
var url = string.IsNullOrWhiteSpace(installerBaseUrl)
|
||||||
|
? null
|
||||||
|
: $"{installerBaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}";
|
||||||
|
result.Add(new InstallerMirrorDocument(
|
||||||
|
Platform: platform,
|
||||||
|
Arch: ResolveArch(platform),
|
||||||
|
Url: url,
|
||||||
|
Name: fileName,
|
||||||
|
FileName: fileName,
|
||||||
|
Sha256: ComputeSha256(destinationPath),
|
||||||
|
Size: new FileInfo(destinationPath).Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveArch(string platform)
|
||||||
|
{
|
||||||
|
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "x86";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "arm64";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "x64";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldIgnore(string relativePath)
|
||||||
|
{
|
||||||
|
var normalized = relativePath.Trim().Replace('\\', '/');
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.Equals(".current", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals(".partial", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals(".destroy", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith(".current/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith(".partial/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith(".destroy/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CopyContentObject(string sourcePath, string repoRoot, string sha256)
|
||||||
|
{
|
||||||
|
var prefix = sha256[..Math.Min(2, sha256.Length)];
|
||||||
|
var relativeKey = $"{prefix}/{sha256}";
|
||||||
|
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||||
|
if (!File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativeKey.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSha256(string filePath)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteJson<T>(string path, T value)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||||
|
File.WriteAllText(path, json, new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size);
|
||||||
|
|
||||||
|
private sealed record FileMapDocument(
|
||||||
|
string FormatVersion,
|
||||||
|
string DistributionId,
|
||||||
|
string FromVersion,
|
||||||
|
string ToVersion,
|
||||||
|
string Platform,
|
||||||
|
string Channel,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
IReadOnlyList<string> Capabilities,
|
||||||
|
IReadOnlyList<ComponentDocument> Components,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata);
|
||||||
|
|
||||||
|
private sealed record DistributionDocument(
|
||||||
|
string DistributionId,
|
||||||
|
string Version,
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
string? FileMapUrl,
|
||||||
|
string? FileMapSignatureUrl,
|
||||||
|
IReadOnlyList<ComponentDocument> Components,
|
||||||
|
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
|
||||||
|
IReadOnlyList<string> Capabilities,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata);
|
||||||
|
|
||||||
|
private sealed record LatestPointerDocument(
|
||||||
|
string DistributionId,
|
||||||
|
string Version,
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset PublishedAt);
|
||||||
|
|
||||||
|
private sealed record ComponentDocument(
|
||||||
|
string Id,
|
||||||
|
string Root,
|
||||||
|
string Mode,
|
||||||
|
IReadOnlyList<FileEntryDocument> Files,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata);
|
||||||
|
|
||||||
|
private sealed record FileEntryDocument(
|
||||||
|
string Path,
|
||||||
|
string Action,
|
||||||
|
string Sha256,
|
||||||
|
long Size,
|
||||||
|
string Mode,
|
||||||
|
string? ObjectKey,
|
||||||
|
string? ObjectUrl,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata);
|
||||||
|
|
||||||
|
private sealed record InstallerMirrorDocument(
|
||||||
|
string Platform,
|
||||||
|
string Arch,
|
||||||
|
string? Url,
|
||||||
|
string? Name,
|
||||||
|
string? FileName,
|
||||||
|
string? Sha256,
|
||||||
|
long Size);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed record PlondsPublishOptions(
|
||||||
|
string Version,
|
||||||
|
string AppArtifactsRoot,
|
||||||
|
string InstallerArtifactsRoot,
|
||||||
|
string OutputRoot,
|
||||||
|
string PrivateKeyPath,
|
||||||
|
string Channel = "stable",
|
||||||
|
string? BaselineRoot = null,
|
||||||
|
string? RepoBaseUrl = null,
|
||||||
|
string? InstallerBaseUrl = null,
|
||||||
|
string IncrementalStrategy = "release-payload",
|
||||||
|
string? BaselineVersion = null,
|
||||||
|
string? BaselineRef = null,
|
||||||
|
string? SourceCommit = null,
|
||||||
|
bool IsFullPayloadRelease = false,
|
||||||
|
string? CommitRangeStart = null,
|
||||||
|
string? CommitRangeEnd = null);
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Plonds.Core.Security;
|
||||||
|
using Plonds.Shared;
|
||||||
|
using Plonds.Shared.Models;
|
||||||
|
|
||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed class PlondsPublisher
|
||||||
|
{
|
||||||
|
private static readonly PlatformConfig[] SupportedPlatforms =
|
||||||
|
[
|
||||||
|
new("windows-x64", "app-payload-windows-x64", [".exe"], ["x64"]),
|
||||||
|
new("windows-x86", "app-payload-windows-x86", [".exe"], ["x86"]),
|
||||||
|
new("linux-x64", "app-payload-linux-x64", [".deb"], ["linux", "x64"])
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly PlondsGenerator _generator = new();
|
||||||
|
private readonly RsaFileSigner _signer = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<PlatformPublishResult> Publish(PlondsPublishOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var results = new List<PlatformPublishResult>();
|
||||||
|
var releaseAssetsRoot = Path.Combine(Path.GetFullPath(options.OutputRoot), "release-assets");
|
||||||
|
Directory.CreateDirectory(releaseAssetsRoot);
|
||||||
|
|
||||||
|
foreach (var config in SupportedPlatforms)
|
||||||
|
{
|
||||||
|
var artifactRoot = Path.Combine(Path.GetFullPath(options.AppArtifactsRoot), config.ArtifactName);
|
||||||
|
if (!Directory.Exists(artifactRoot))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException($"App payload artifact root not found for {config.Platform}: {artifactRoot}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentAppDirectory = FindCurrentAppDirectory(artifactRoot, options.Version);
|
||||||
|
if (currentAppDirectory is null)
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException($"Unable to locate app payload directory for {config.Platform} under {artifactRoot}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var baselineRoot = string.IsNullOrWhiteSpace(options.BaselineRoot)
|
||||||
|
? Path.Combine(Path.GetFullPath(options.OutputRoot), "_baselines")
|
||||||
|
: Path.GetFullPath(options.BaselineRoot);
|
||||||
|
var platformBaselineRoot = Path.Combine(baselineRoot, config.Platform);
|
||||||
|
var previousDirectory = Path.Combine(platformBaselineRoot, "current");
|
||||||
|
var previousVersionPath = Path.Combine(platformBaselineRoot, "version.txt");
|
||||||
|
Directory.CreateDirectory(platformBaselineRoot);
|
||||||
|
if (!Directory.Exists(previousDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(previousDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousVersion = File.Exists(previousVersionPath)
|
||||||
|
? File.ReadAllText(previousVersionPath).Trim()
|
||||||
|
: "0.0.0";
|
||||||
|
|
||||||
|
var installerSourceDirectory = PrepareInstallerMirrorInput(
|
||||||
|
config,
|
||||||
|
options.InstallerArtifactsRoot,
|
||||||
|
Path.Combine(platformBaselineRoot, "installers"));
|
||||||
|
|
||||||
|
var distributionId = $"plonds-{options.Version}-{config.Platform}";
|
||||||
|
var repoBaseUrl = options.RepoBaseUrl;
|
||||||
|
var fileMapUrl = repoBaseUrl is null
|
||||||
|
? null
|
||||||
|
: $"{repoBaseUrl.TrimEnd('/').Replace("/repo/sha256", "/manifests")}/{distributionId}/plonds-filemap.json";
|
||||||
|
var fileMapSignatureUrl = fileMapUrl is null ? null : fileMapUrl + ".sig";
|
||||||
|
var installerBaseUrl = string.IsNullOrWhiteSpace(options.InstallerBaseUrl)
|
||||||
|
? null
|
||||||
|
: $"{options.InstallerBaseUrl.TrimEnd('/')}/{config.Platform}/{options.Version}";
|
||||||
|
|
||||||
|
var result = _generator.Generate(new PlondsGenerateOptions(
|
||||||
|
CurrentVersion: options.Version,
|
||||||
|
CurrentDirectory: currentAppDirectory,
|
||||||
|
Platform: config.Platform,
|
||||||
|
OutputRoot: options.OutputRoot,
|
||||||
|
PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
|
||||||
|
PreviousDirectory: previousDirectory,
|
||||||
|
Channel: options.Channel,
|
||||||
|
DistributionId: distributionId,
|
||||||
|
RepoBaseUrl: repoBaseUrl,
|
||||||
|
FileMapUrl: fileMapUrl,
|
||||||
|
FileMapSignatureUrl: fileMapSignatureUrl,
|
||||||
|
InstallerDirectory: installerSourceDirectory,
|
||||||
|
InstallerBaseUrl: installerBaseUrl,
|
||||||
|
IncrementalStrategy: options.IncrementalStrategy,
|
||||||
|
BaselineVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
|
||||||
|
BaselineRef: options.BaselineRef,
|
||||||
|
SourceCommit: options.SourceCommit,
|
||||||
|
IsFullPayloadRelease: options.IsFullPayloadRelease,
|
||||||
|
CommitRangeStart: options.CommitRangeStart,
|
||||||
|
CommitRangeEnd: options.CommitRangeEnd));
|
||||||
|
|
||||||
|
_signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);
|
||||||
|
|
||||||
|
CopyReleaseAsset(result.FileMapPath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json"));
|
||||||
|
CopyReleaseAsset(result.SignaturePath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json.sig"));
|
||||||
|
CopyReleaseAsset(result.DistributionPath, Path.Combine(releaseAssetsRoot, $"plonds-distribution-{config.Platform}.json"));
|
||||||
|
CopyReleaseAsset(result.LatestPath, Path.Combine(releaseAssetsRoot, $"plonds-latest-{config.Platform}.json"));
|
||||||
|
|
||||||
|
MirrorBaseline(currentAppDirectory, previousDirectory, previousVersionPath, options.Version);
|
||||||
|
results.Add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteMetadataCatalog(options, results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteMetadataCatalog(PlondsPublishOptions options, IReadOnlyList<PlatformPublishResult> results)
|
||||||
|
{
|
||||||
|
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||||
|
var metadataRoot = Path.Combine(outputRoot, "meta");
|
||||||
|
Directory.CreateDirectory(metadataRoot);
|
||||||
|
|
||||||
|
var generatedAt = DateTimeOffset.UtcNow;
|
||||||
|
var latestPointers = results
|
||||||
|
.Select(result => new PlondsChannelPointer(
|
||||||
|
Channel: options.Channel,
|
||||||
|
Platform: result.Platform,
|
||||||
|
DistributionId: result.DistributionId,
|
||||||
|
Version: options.Version,
|
||||||
|
PublishedAt: generatedAt,
|
||||||
|
DistributionPath: $"distributions/{result.DistributionId}.json",
|
||||||
|
FileMapPath: $"../manifests/{result.DistributionId}/plonds-filemap.json"))
|
||||||
|
.OrderBy(pointer => pointer.Channel, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(pointer => pointer.Platform, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var catalog = new PlondsMetadataCatalog(
|
||||||
|
ProtocolName: PlondsConstants.ProtocolName,
|
||||||
|
ProtocolVersion: PlondsConstants.ProtocolVersion,
|
||||||
|
StorageRoot: outputRoot,
|
||||||
|
MetaRoot: metadataRoot,
|
||||||
|
Latest: latestPointers,
|
||||||
|
Metadata: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["generatedBy"] = "Plonds.Tool",
|
||||||
|
["channel"] = options.Channel,
|
||||||
|
["generatedAt"] = generatedAt.ToString("O")
|
||||||
|
});
|
||||||
|
|
||||||
|
var metadataPath = Path.Combine(metadataRoot, "metadata.json");
|
||||||
|
File.WriteAllText(metadataPath, JsonSerializer.Serialize(catalog, JsonOptions), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MirrorBaseline(string currentAppDirectory, string previousDirectory, string previousVersionPath, string version)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(previousDirectory))
|
||||||
|
{
|
||||||
|
Directory.Delete(previousDirectory, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyDirectory(currentAppDirectory, previousDirectory);
|
||||||
|
File.WriteAllText(previousVersionPath, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindCurrentAppDirectory(string artifactRoot, string version)
|
||||||
|
{
|
||||||
|
var preferred = Directory.EnumerateDirectories(artifactRoot, $"app-{version}", SearchOption.AllDirectories).FirstOrDefault();
|
||||||
|
if (preferred is not null)
|
||||||
|
{
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Directory.EnumerateDirectories(artifactRoot, "app-*", SearchOption.AllDirectories)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PrepareInstallerMirrorInput(PlatformConfig config, string installerArtifactsRoot, string destinationRoot)
|
||||||
|
{
|
||||||
|
var installerFiles = FindInstallerFiles(config, installerArtifactsRoot);
|
||||||
|
if (Directory.Exists(destinationRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(destinationRoot, recursive: true);
|
||||||
|
}
|
||||||
|
Directory.CreateDirectory(destinationRoot);
|
||||||
|
|
||||||
|
foreach (var file in installerFiles)
|
||||||
|
{
|
||||||
|
File.Copy(file, Path.Combine(destinationRoot, Path.GetFileName(file)), overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return destinationRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> FindInstallerFiles(PlatformConfig config, string installerArtifactsRoot)
|
||||||
|
{
|
||||||
|
var files = Directory.EnumerateFiles(Path.GetFullPath(installerArtifactsRoot), "*", SearchOption.AllDirectories);
|
||||||
|
return files
|
||||||
|
.Where(file => config.InstallerExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
|
||||||
|
.Where(file =>
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(file);
|
||||||
|
return config.FileNameTokens.All(token => fileName.Contains(token, StringComparison.OrdinalIgnoreCase));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyReleaseAsset(string sourcePath, string destinationPath)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||||
|
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyDirectory(string sourceDir, string destinationDir)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(destinationDir);
|
||||||
|
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(sourceDir, directory);
|
||||||
|
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(sourceDir, file);
|
||||||
|
var destinationPath = Path.Combine(destinationDir, relativePath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||||
|
File.Copy(file, destinationPath, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PlatformConfig(
|
||||||
|
string Platform,
|
||||||
|
string ArtifactName,
|
||||||
|
IReadOnlyList<string> InstallerExtensions,
|
||||||
|
IReadOnlyList<string> FileNameTokens);
|
||||||
|
}
|
||||||
@@ -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,38 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Plonds.Core.Security;
|
||||||
|
|
||||||
|
public sealed class RsaFileSigner
|
||||||
|
{
|
||||||
|
public string SignFile(string filePath, string privateKeyPath, string? outputPath = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPath);
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Manifest file not found.", filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(privateKeyPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Private key PEM file not found.", privateKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath ??= filePath + ".sig";
|
||||||
|
|
||||||
|
var payload = File.ReadAllBytes(filePath);
|
||||||
|
var privateKeyPem = File.ReadAllText(privateKeyPath, Encoding.ASCII);
|
||||||
|
if (string.IsNullOrWhiteSpace(privateKeyPem))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Private key PEM is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(privateKeyPem);
|
||||||
|
var signature = rsa.SignData(payload, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
File.WriteAllText(outputPath, Convert.ToBase64String(signature), Encoding.ASCII);
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsChannelPointer(
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
string DistributionId,
|
||||||
|
string Version,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
string? DistributionPath = null,
|
||||||
|
string? FileMapPath = null);
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsComponent(
|
||||||
|
string Id,
|
||||||
|
string Root,
|
||||||
|
string Mode,
|
||||||
|
IReadOnlyList<PlondsFileEntry> Files,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsDistributionInfo(
|
||||||
|
string DistributionId,
|
||||||
|
string Version,
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
IReadOnlyList<PlondsComponent> Components,
|
||||||
|
IReadOnlyList<PlondsMirrorAsset> InstallerMirrors,
|
||||||
|
IReadOnlyList<string> Capabilities,
|
||||||
|
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsFileEntry(
|
||||||
|
string Path,
|
||||||
|
string Op,
|
||||||
|
string ContentHash,
|
||||||
|
long Size,
|
||||||
|
string Mode,
|
||||||
|
string? ObjectKey = null,
|
||||||
|
string? Compression = null,
|
||||||
|
string? PatchBaseHash = null,
|
||||||
|
string? PatchObjectKey = null);
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsFileMap(
|
||||||
|
string FormatVersion,
|
||||||
|
string DistributionId,
|
||||||
|
string SourceVersion,
|
||||||
|
string TargetVersion,
|
||||||
|
string Platform,
|
||||||
|
IReadOnlyList<PlondsComponent> Components,
|
||||||
|
IReadOnlyList<string> Capabilities,
|
||||||
|
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsMetadataCatalog(
|
||||||
|
string ProtocolName,
|
||||||
|
string ProtocolVersion,
|
||||||
|
string StorageRoot,
|
||||||
|
string MetaRoot,
|
||||||
|
IReadOnlyList<PlondsChannelPointer> Latest,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsMirrorAsset(
|
||||||
|
string Platform,
|
||||||
|
string Arch,
|
||||||
|
string Url,
|
||||||
|
string? FileName = null,
|
||||||
|
string? Sha256 = null,
|
||||||
|
long Size = 0);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsSignatureDescriptor(
|
||||||
|
string Algorithm,
|
||||||
|
string KeyId,
|
||||||
|
string Signature);
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Plonds.Shared</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Plonds.Shared;
|
||||||
|
|
||||||
|
public static class PlondsConstants
|
||||||
|
{
|
||||||
|
public const string ProtocolName = "PLONDS";
|
||||||
|
public const string ProtocolVersion = "1.0";
|
||||||
|
|
||||||
|
public const string DefaultApiBasePath = "/api/plonds/v1";
|
||||||
|
public const string DefaultStorageRoot = "sample-data";
|
||||||
|
public const string DefaultMetaRoot = "meta";
|
||||||
|
public const string DefaultRepoRoot = "repo";
|
||||||
|
public const string DefaultInstallersRoot = "installers";
|
||||||
|
|
||||||
|
public const string FileObjectMode = "file-object";
|
||||||
|
public const string CompressedObjectMode = "compressed-object";
|
||||||
|
public const string BinaryPatchMode = "binary-patch";
|
||||||
|
|
||||||
|
public static readonly string[] SupportedFileModes =
|
||||||
|
[
|
||||||
|
FileObjectMode,
|
||||||
|
CompressedObjectMode,
|
||||||
|
BinaryPatchMode
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Plonds.Shared;
|
||||||
|
|
||||||
|
public enum PlondsFileOperation
|
||||||
|
{
|
||||||
|
Add,
|
||||||
|
Replace,
|
||||||
|
Reuse,
|
||||||
|
Delete
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Plonds.Core\Plonds.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using Plonds.Core.Publishing;
|
||||||
|
using Plonds.Core.Security;
|
||||||
|
|
||||||
|
return await PlondsCli.RunAsync(args);
|
||||||
|
|
||||||
|
internal static class PlondsCli
|
||||||
|
{
|
||||||
|
public static Task<int> RunAsync(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length == 0)
|
||||||
|
{
|
||||||
|
PrintUsage();
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = args[0].Trim().ToLowerInvariant();
|
||||||
|
var options = ParseOptions(args.Skip(1).ToArray());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case "generate":
|
||||||
|
RunGenerate(options);
|
||||||
|
return Task.FromResult(0);
|
||||||
|
case "sign":
|
||||||
|
RunSign(options);
|
||||||
|
return Task.FromResult(0);
|
||||||
|
case "publish":
|
||||||
|
RunPublish(options);
|
||||||
|
return Task.FromResult(0);
|
||||||
|
case "pack-payload":
|
||||||
|
RunPackPayload(options);
|
||||||
|
return Task.FromResult(0);
|
||||||
|
case "build-delta":
|
||||||
|
RunBuildDelta(options);
|
||||||
|
return Task.FromResult(0);
|
||||||
|
case "build-index":
|
||||||
|
RunBuildIndex(options);
|
||||||
|
return Task.FromResult(0);
|
||||||
|
case "build-ddss":
|
||||||
|
RunBuildDdss(options);
|
||||||
|
return Task.FromResult(0);
|
||||||
|
default:
|
||||||
|
Console.Error.WriteLine($"Unknown command: {command}");
|
||||||
|
PrintUsage();
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(ex.Message);
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunGenerate(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var generator = new PlondsGenerator();
|
||||||
|
var result = generator.Generate(new PlondsGenerateOptions(
|
||||||
|
CurrentVersion: Require(options, "current-version"),
|
||||||
|
CurrentDirectory: Require(options, "current-dir"),
|
||||||
|
Platform: Require(options, "platform"),
|
||||||
|
OutputRoot: Require(options, "output-dir"),
|
||||||
|
PreviousVersion: Get(options, "previous-version", "0.0.0") ?? "0.0.0",
|
||||||
|
PreviousDirectory: Get(options, "previous-dir"),
|
||||||
|
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||||
|
DistributionId: Get(options, "distribution-id"),
|
||||||
|
RepoBaseUrl: Get(options, "repo-base-url"),
|
||||||
|
FileMapUrl: Get(options, "file-map-url"),
|
||||||
|
FileMapSignatureUrl: Get(options, "file-map-signature-url"),
|
||||||
|
InstallerDirectory: Get(options, "installer-directory"),
|
||||||
|
InstallerBaseUrl: Get(options, "installer-base-url")));
|
||||||
|
|
||||||
|
Console.WriteLine($"Generated PLONDS artifacts for {result.Platform}: {result.DistributionId}");
|
||||||
|
Console.WriteLine(result.FileMapPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunSign(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var signer = new RsaFileSigner();
|
||||||
|
var signaturePath = signer.SignFile(
|
||||||
|
Require(options, "manifest"),
|
||||||
|
Require(options, "private-key"),
|
||||||
|
Get(options, "output"));
|
||||||
|
Console.WriteLine(signaturePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunPublish(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var publisher = new PlondsPublisher();
|
||||||
|
var results = publisher.Publish(new PlondsPublishOptions(
|
||||||
|
Version: Require(options, "version"),
|
||||||
|
AppArtifactsRoot: Require(options, "app-artifacts-root"),
|
||||||
|
InstallerArtifactsRoot: Require(options, "installer-artifacts-root"),
|
||||||
|
OutputRoot: Require(options, "output-dir"),
|
||||||
|
PrivateKeyPath: Require(options, "private-key"),
|
||||||
|
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||||
|
BaselineRoot: Get(options, "baseline-root"),
|
||||||
|
RepoBaseUrl: Get(options, "repo-base-url"),
|
||||||
|
InstallerBaseUrl: Get(options, "installer-base-url"),
|
||||||
|
IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload",
|
||||||
|
BaselineVersion: Get(options, "baseline-version"),
|
||||||
|
BaselineRef: Get(options, "baseline-ref"),
|
||||||
|
SourceCommit: Get(options, "source-commit"),
|
||||||
|
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease,
|
||||||
|
CommitRangeStart: Get(options, "commit-range-start"),
|
||||||
|
CommitRangeEnd: Get(options, "commit-range-end")));
|
||||||
|
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunPackPayload(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var sourceDirectory = Require(options, "source-dir");
|
||||||
|
var outputZip = Require(options, "output-zip");
|
||||||
|
PayloadUtilities.CreatePayloadZip(sourceDirectory, outputZip);
|
||||||
|
Console.WriteLine(outputZip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunBuildDelta(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var builder = new PlondsDeltaBuilder();
|
||||||
|
var result = builder.Build(new PlondsDeltaBuildOptions(
|
||||||
|
Platform: Require(options, "platform"),
|
||||||
|
CurrentVersion: Require(options, "current-version"),
|
||||||
|
CurrentTag: Require(options, "current-tag"),
|
||||||
|
CurrentPayloadZip: Require(options, "current-zip"),
|
||||||
|
OutputRoot: Require(options, "output-dir"),
|
||||||
|
PrivateKeyPath: Require(options, "private-key"),
|
||||||
|
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||||
|
BaselineVersion: Get(options, "baseline-version"),
|
||||||
|
BaselineTag: Get(options, "baseline-tag"),
|
||||||
|
BaselinePayloadZip: Get(options, "baseline-zip"),
|
||||||
|
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload));
|
||||||
|
|
||||||
|
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
|
||||||
|
Console.WriteLine(result.FileMapPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunBuildIndex(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var builder = new PlondsReleaseIndexBuilder();
|
||||||
|
var manifestPath = builder.Build(new PlondsReleaseIndexOptions(
|
||||||
|
ReleaseTag: Require(options, "release-tag"),
|
||||||
|
Version: Require(options, "version"),
|
||||||
|
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||||
|
PlatformSummariesDirectory: Require(options, "platform-summaries-dir"),
|
||||||
|
OutputRoot: Require(options, "output-dir"),
|
||||||
|
PrivateKeyPath: Require(options, "private-key")));
|
||||||
|
|
||||||
|
Console.WriteLine(manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunBuildDdss(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var builder = new DdssManifestBuilder();
|
||||||
|
var manifestPath = builder.Build(new DdssBuildOptions(
|
||||||
|
ReleaseTag: Require(options, "release-tag"),
|
||||||
|
AssetsDirectory: Require(options, "assets-dir"),
|
||||||
|
OutputRoot: Require(options, "output-dir"),
|
||||||
|
PrivateKeyPath: Require(options, "private-key"),
|
||||||
|
Repository: Require(options, "repository"),
|
||||||
|
S3BaseUrl: Get(options, "s3-base-url")));
|
||||||
|
|
||||||
|
Console.WriteLine(manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var index = 0; index < args.Length; index++)
|
||||||
|
{
|
||||||
|
var token = args[index];
|
||||||
|
if (!token.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = token[2..];
|
||||||
|
var value = index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal)
|
||||||
|
? args[++index]
|
||||||
|
: "true";
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Require(IReadOnlyDictionary<string, string> options, string key)
|
||||||
|
{
|
||||||
|
if (options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Missing required option --{key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Get(IReadOnlyDictionary<string, string> options, string key, string? defaultValue = null)
|
||||||
|
{
|
||||||
|
return options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||||
|
? value
|
||||||
|
: defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintUsage()
|
||||||
|
{
|
||||||
|
Console.WriteLine("PLONDS Tool");
|
||||||
|
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
|
||||||
|
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload]");
|
||||||
|
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
|
||||||
|
Console.WriteLine(" build-ddss --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
|
||||||
|
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
|
||||||
|
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
|
||||||
|
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
|
||||||
|
}
|
||||||
|
}
|
||||||
31
phainon.yml
31
phainon.yml
@@ -1,7 +1,5 @@
|
|||||||
# Phainon Distribution Center (PDC) publish configuration
|
# Phainon Distribution Center Client Configuration
|
||||||
# This file is intentionally conservative: Launcher remains installer/rollback authority.
|
|
||||||
name: "LanMountainDesktop"
|
name: "LanMountainDesktop"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
app:
|
app:
|
||||||
allowDiffUpdate: true
|
allowDiffUpdate: true
|
||||||
@@ -13,17 +11,22 @@ components:
|
|||||||
includes:
|
includes:
|
||||||
- "**"
|
- "**"
|
||||||
excludes:
|
excludes:
|
||||||
- "app-*/**"
|
- "app*/**"
|
||||||
- ".launcher/update/incoming/**"
|
- "files*.json"
|
||||||
- "files.json"
|
- "files*.json.sig"
|
||||||
- "files.json.sig"
|
- "update*.zip"
|
||||||
- "update.zip"
|
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
number: 0
|
number: 0
|
||||||
|
fileRepoRoot: "__FILE_REPO_ROOT__"
|
||||||
|
archiveRoot: "__ARCHIVE_ROOT__/$(primaryVersion)/$(version)/"
|
||||||
|
bucketKeyRoot: "lanmountain/update/repo/"
|
||||||
|
archiveBucketKeyRoot: "lanmountain/update/archive/$(primaryVersion)/$(version)/"
|
||||||
|
appChangeLogPath: "$(thisFileDir)/../CHANGELOG.md"
|
||||||
|
appChangeLogTemplate: |
|
||||||
|
$(changeLog)
|
||||||
|
|
||||||
# Replace these roots in CI/CD or environment-specific templates when enabling PDCC publish.
|
---
|
||||||
fileRepoRoot: "https://example.invalid/lanmountain/distribution-v1/repo/"
|
|
||||||
archiveRoot: "https://example.invalid/lanmountain/distribution-v1/$(primaryVersion)/$(version)/"
|
## Checksums And Downloads
|
||||||
bucketKeyRoot: "lanmountain/distribution-v1/repo/"
|
|
||||||
archiveBucketKeyRoot: "lanmountain/distribution-v1/$(primaryVersion)/$(version)/"
|
$(hashes)
|
||||||
|
|||||||
87
scripts/Generate-PlondsArtifacts.ps1
Normal file
87
scripts/Generate-PlondsArtifacts.ps1
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$CurrentVersion,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$CurrentDir,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Platform,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OutputDir,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$PreviousVersion = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$PreviousDir = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$Channel = "stable",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$DistributionId = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$RepoBaseUrl = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$FileMapUrl = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$FileMapSignatureUrl = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$InstallerDirectory = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$InstallerBaseUrl = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$toolProject = Join-Path $PSScriptRoot "..\PenguinLogisticsOnlineNetworkDistributionSystem\src\Plonds.Tool\Plonds.Tool.csproj"
|
||||||
|
if (-not (Test-Path -LiteralPath $toolProject)) {
|
||||||
|
throw "PLONDS tool project not found: $toolProject"
|
||||||
|
}
|
||||||
|
|
||||||
|
$arguments = @(
|
||||||
|
"run",
|
||||||
|
"--project", $toolProject,
|
||||||
|
"--",
|
||||||
|
"generate",
|
||||||
|
"--current-version", $CurrentVersion,
|
||||||
|
"--current-dir", $CurrentDir,
|
||||||
|
"--platform", $Platform,
|
||||||
|
"--output-dir", $OutputDir,
|
||||||
|
"--previous-version", $(if ([string]::IsNullOrWhiteSpace($PreviousVersion)) { "0.0.0" } else { $PreviousVersion }),
|
||||||
|
"--channel", $Channel
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($PreviousDir)) {
|
||||||
|
$arguments += @("--previous-dir", $PreviousDir)
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($DistributionId)) {
|
||||||
|
$arguments += @("--distribution-id", $DistributionId)
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($RepoBaseUrl)) {
|
||||||
|
$arguments += @("--repo-base-url", $RepoBaseUrl)
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($FileMapUrl)) {
|
||||||
|
$arguments += @("--file-map-url", $FileMapUrl)
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($FileMapSignatureUrl)) {
|
||||||
|
$arguments += @("--file-map-signature-url", $FileMapSignatureUrl)
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($InstallerDirectory)) {
|
||||||
|
$arguments += @("--installer-directory", $InstallerDirectory)
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($InstallerBaseUrl)) {
|
||||||
|
$arguments += @("--installer-base-url", $InstallerBaseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
& dotnet @arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "PLONDS generate command failed."
|
||||||
|
}
|
||||||
97
scripts/Install-Pdcc.ps1
Normal file
97
scripts/Install-Pdcc.ps1
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
param(
|
||||||
|
[string]$Repository = "ClassIsland/PhainonDistributionCenter",
|
||||||
|
[string]$AssetName = "out_app_linux_x64.zip",
|
||||||
|
[string]$Version = "",
|
||||||
|
[string]$OutputDir = (Join-Path $PSScriptRoot "..\pdcc")
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Repository)) {
|
||||||
|
throw "Repository is required."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($AssetName)) {
|
||||||
|
throw "AssetName is required."
|
||||||
|
}
|
||||||
|
|
||||||
|
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||||
|
if (-not (Test-Path -LiteralPath $OutputDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientName = if ($env:OS -eq "Windows_NT") { "PhainonDistributionCenter.Client.exe" } else { "PhainonDistributionCenter.Client" }
|
||||||
|
$clientPath = Join-Path $OutputDir $clientName
|
||||||
|
if (Test-Path -LiteralPath $clientPath) {
|
||||||
|
Write-Host "PDCC client already installed at $clientPath"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseTag = $Version
|
||||||
|
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||||
|
$releaseTag = $env:PDC_CLIENT_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||||
|
$releaseTag = $env:PDCC_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempDir = Join-Path $env:RUNNER_TEMP "pdcc-install"
|
||||||
|
if (Test-Path -LiteralPath $tempDir) {
|
||||||
|
Remove-Item -LiteralPath $tempDir -Recurse -Force
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||||
|
|
||||||
|
$zipPath = Join-Path $tempDir $AssetName
|
||||||
|
|
||||||
|
if (Get-Command gh -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "Downloading PDCC via gh release download from $Repository ..."
|
||||||
|
$ghArgs = @("release", "download", "--repo", $Repository, "--pattern", $AssetName, "--dir", $tempDir, "--clobber")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||||
|
$ghArgs = @("release", "download", $releaseTag, "--repo", $Repository, "--pattern", $AssetName, "--dir", $tempDir, "--clobber")
|
||||||
|
}
|
||||||
|
|
||||||
|
& gh @ghArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "gh release download failed for $Repository/$AssetName."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||||
|
throw "PDCC_VERSION is required when gh is unavailable."
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadUrl = "https://github.com/$Repository/releases/download/$releaseTag/$AssetName"
|
||||||
|
Write-Host "Downloading PDCC from $downloadUrl ..."
|
||||||
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath
|
||||||
|
}
|
||||||
|
|
||||||
|
$extractDir = Join-Path $tempDir "extract"
|
||||||
|
if (Test-Path -LiteralPath $extractDir) {
|
||||||
|
Remove-Item -LiteralPath $extractDir -Recurse -Force
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
||||||
|
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
|
||||||
|
|
||||||
|
$copied = $false
|
||||||
|
foreach ($file in Get-ChildItem -LiteralPath $extractDir -Recurse -File) {
|
||||||
|
if ($file.Name -ieq $clientName) {
|
||||||
|
Copy-Item -LiteralPath $file.FullName -Destination $clientPath -Force
|
||||||
|
$copied = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $copied) {
|
||||||
|
throw "PDCC client executable not found in downloaded archive."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($IsLinux) {
|
||||||
|
try {
|
||||||
|
chmod +x $clientPath | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "PDCC installed to $clientPath"
|
||||||
59
scripts/Prepare-PdccOut.ps1
Normal file
59
scripts/Prepare-PdccOut.ps1
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SourceDir,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OutputDir,
|
||||||
|
|
||||||
|
[string]$PlatformKey = "",
|
||||||
|
|
||||||
|
[string[]]$InstallerFiles = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$SourceDir = [System.IO.Path]::GetFullPath($SourceDir)
|
||||||
|
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $SourceDir)) {
|
||||||
|
throw "Source directory not found: $SourceDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $OutputDir) {
|
||||||
|
Remove-Item -LiteralPath $OutputDir -Recurse -Force
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||||
|
|
||||||
|
$payloadRoot = if ([string]::IsNullOrWhiteSpace($PlatformKey)) {
|
||||||
|
$OutputDir
|
||||||
|
} else {
|
||||||
|
Join-Path $OutputDir $PlatformKey
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $payloadRoot -Force | Out-Null
|
||||||
|
Get-ChildItem -LiteralPath $SourceDir -Force | ForEach-Object {
|
||||||
|
Copy-Item -LiteralPath $_.FullName -Destination $payloadRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($InstallerFiles.Count -gt 0) {
|
||||||
|
$installerRoot = Join-Path $OutputDir "installers"
|
||||||
|
if (-not (Test-Path -LiteralPath $installerRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $installerRoot -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($installer in $InstallerFiles) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($installer)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$installerPath = [System.IO.Path]::GetFullPath($installer)
|
||||||
|
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||||
|
throw "Installer file not found: $installerPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = Join-Path $installerRoot ([System.IO.Path]::GetFileName($installerPath))
|
||||||
|
Copy-Item -LiteralPath $installerPath -Destination $targetPath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Prepared PDCC staging directory: $payloadRoot"
|
||||||
1044
scripts/Publish-Plonds.ps1
Normal file
1044
scripts/Publish-Plonds.ps1
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$FilesJsonPath,
|
[string]$FilesJsonPath,
|
||||||
|
|
||||||
@@ -11,46 +11,16 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
|
||||||
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
|
|
||||||
throw "Manifest file not found: $FilesJsonPath"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
|
|
||||||
throw "Private key file not found: $PrivateKeyPath"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
$OutputPath = "$FilesJsonPath.sig"
|
$OutputPath = "$FilesJsonPath.sig"
|
||||||
}
|
}
|
||||||
|
|
||||||
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
|
$toolProject = Join-Path $PSScriptRoot "..\PenguinLogisticsOnlineNetworkDistributionSystem\src\Plonds.Tool\Plonds.Tool.csproj"
|
||||||
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
|
if (-not (Test-Path -LiteralPath $toolProject)) {
|
||||||
|
throw "PLONDS tool project not found: $toolProject"
|
||||||
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
|
|
||||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
|
||||||
throw "Private key PEM is empty: $PrivateKeyPath"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
& dotnet run --project $toolProject -- sign --manifest $FilesJsonPath --private-key $PrivateKeyPath --output $OutputPath
|
||||||
try {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
$rsa.ImportFromPem($privateKeyPem)
|
throw "PLONDS sign command failed."
|
||||||
$signatureBytes = $rsa.SignData(
|
|
||||||
$manifestBytes,
|
|
||||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
|
||||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
finally {
|
|
||||||
$rsa.Dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
|
|
||||||
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
|
|
||||||
|
|
||||||
Write-Host "Signed manifest file."
|
|
||||||
Write-Host "Manifest: $FilesJsonPath"
|
|
||||||
Write-Host "Signature: $OutputPath"
|
|
||||||
|
|||||||
206
scripts/pdc-mock-server.py
Normal file
206
scripts/pdc-mock-server.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_text() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class PdcMockHandler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
token = ""
|
||||||
|
data_dir = Path(".")
|
||||||
|
|
||||||
|
def _write_json(self, status_code: int, payload: dict) -> None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status_code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
self.wfile.flush()
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
|
def handle_expect_100(self) -> bool:
|
||||||
|
self.send_response_only(100)
|
||||||
|
self.end_headers()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _read_chunked_body(self) -> bytes:
|
||||||
|
chunks = bytearray()
|
||||||
|
while True:
|
||||||
|
size_line = self.rfile.readline()
|
||||||
|
if not size_line:
|
||||||
|
break
|
||||||
|
|
||||||
|
size_line = size_line.strip()
|
||||||
|
if not size_line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
size_text = size_line.split(b";", 1)[0]
|
||||||
|
chunk_size = int(size_text, 16)
|
||||||
|
if chunk_size == 0:
|
||||||
|
# Consume optional trailer headers until the terminating blank line.
|
||||||
|
while True:
|
||||||
|
trailer = self.rfile.readline()
|
||||||
|
if trailer in (b"", b"\r\n", b"\n"):
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
remaining = chunk_size
|
||||||
|
while remaining > 0:
|
||||||
|
part = self.rfile.read(remaining)
|
||||||
|
if not part:
|
||||||
|
raise ConnectionError("unexpected end of stream while reading chunked request body")
|
||||||
|
chunks.extend(part)
|
||||||
|
remaining -= len(part)
|
||||||
|
|
||||||
|
chunk_terminator = self.rfile.read(2)
|
||||||
|
if chunk_terminator == b"\r\n":
|
||||||
|
continue
|
||||||
|
if chunk_terminator[:1] != b"\n":
|
||||||
|
raise ValueError("invalid chunk terminator")
|
||||||
|
|
||||||
|
return bytes(chunks)
|
||||||
|
|
||||||
|
def _read_request_body(self) -> bytes:
|
||||||
|
transfer_encoding = (self.headers.get("Transfer-Encoding") or "").lower()
|
||||||
|
if "chunked" in transfer_encoding:
|
||||||
|
return self._read_chunked_body()
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length <= 0:
|
||||||
|
return b""
|
||||||
|
return self.rfile.read(length)
|
||||||
|
|
||||||
|
def _read_json_body(self) -> tuple[dict, bytes]:
|
||||||
|
raw = self._read_request_body()
|
||||||
|
if not raw:
|
||||||
|
return {}, raw
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8")), raw
|
||||||
|
except Exception:
|
||||||
|
return {}, raw
|
||||||
|
|
||||||
|
def _save_payload(self, name: str, payload: dict, raw_body: bytes) -> None:
|
||||||
|
out = self.data_dir / f"{name}.json"
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"savedAtUtc": _utc_now_text(),
|
||||||
|
"path": self.path,
|
||||||
|
"method": self.command,
|
||||||
|
"headers": {key: value for key, value in self.headers.items()},
|
||||||
|
"rawBodyLength": len(raw_body),
|
||||||
|
"rawBodyPreview": raw_body[:4096].decode("utf-8", errors="replace"),
|
||||||
|
"payload": payload,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_token(self) -> bool:
|
||||||
|
expected = (self.token or "").strip()
|
||||||
|
if not expected:
|
||||||
|
return True
|
||||||
|
provided = (self.headers.get("X-PDC-Token") or "").strip()
|
||||||
|
return provided == expected
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/healthz":
|
||||||
|
self._write_json(200, {"ok": True, "timeUtc": _utc_now_text()})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._write_json(404, {"error": "not_found", "path": self.path})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
print(
|
||||||
|
f"[pdc-mock] {self.command} {self.path} "
|
||||||
|
f"content-length={self.headers.get('Content-Length', '')} "
|
||||||
|
f"transfer-encoding={self.headers.get('Transfer-Encoding', '')} "
|
||||||
|
f"expect={self.headers.get('Expect', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._check_token():
|
||||||
|
self._write_json(401, {"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
|
||||||
|
payload, raw_body = self._read_json_body()
|
||||||
|
|
||||||
|
if self.path == "/api/v1/fileMaps/diff":
|
||||||
|
items = payload.get("items") if isinstance(payload, dict) else {}
|
||||||
|
keys = sorted(items.keys()) if isinstance(items, dict) else []
|
||||||
|
self._save_payload("filemaps-diff-request", payload, raw_body)
|
||||||
|
# CI fallback mode: return empty diff to avoid long object uploads
|
||||||
|
# against a local mock endpoint. Real PDC endpoint will return
|
||||||
|
# actual missing object hashes.
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"content": [],
|
||||||
|
"Content": [],
|
||||||
|
"requestedCount": len(keys),
|
||||||
|
}
|
||||||
|
self._write_json(200, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.path == "/api/v1/fileMaps/upload":
|
||||||
|
self._save_payload("filemaps-upload-request", payload, raw_body)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"content": True,
|
||||||
|
"Content": True,
|
||||||
|
}
|
||||||
|
self._write_json(200, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
m = re.match(r"^/api/v1/distribution/([^/]+)/([^/]+)$", self.path)
|
||||||
|
if m:
|
||||||
|
primary_version = m.group(1)
|
||||||
|
version = m.group(2)
|
||||||
|
self._save_payload("distribution-request", payload, raw_body)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
}
|
||||||
|
self._write_json(200, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._write_json(404, {"error": "not_found", "path": self.path})
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
|
print(f"[pdc-mock] {self.address_string()} - {fmt % args}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="PDC mock server for CI fallback")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=18765)
|
||||||
|
parser.add_argument("--token", default="")
|
||||||
|
parser.add_argument("--data-dir", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
PdcMockHandler.token = args.token
|
||||||
|
PdcMockHandler.data_dir = Path(args.data_dir)
|
||||||
|
PdcMockHandler.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), PdcMockHandler)
|
||||||
|
print(f"[pdc-mock] listening on http://{args.host}:{args.port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user