mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
Compare commits
46 Commits
e8d2575bc1
...
launcher
| 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 | ||
|
|
62e7d96fe7 | ||
|
|
c5ef418bd9 | ||
|
|
1e6b61db85 | ||
|
|
48ce93b68e | ||
|
|
cddebbcf5a | ||
|
|
24b361b5b9 | ||
|
|
833c69305b | ||
|
|
858612fa8e | ||
|
|
f6a6f97e0b | ||
|
|
02547eeea6 | ||
|
|
8e39ea864f | ||
|
|
6343164b24 | ||
|
|
8e21364eed | ||
|
|
4f9feafbbe | ||
|
|
9cf3a15c89 |
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@@ -32,6 +32,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -68,10 +69,18 @@ jobs:
|
||||
libxrender1 libxkbcommon-x11-0 \
|
||||
clang zlib1g-dev
|
||||
|
||||
# Ubuntu 24.04+ moved several packages to t64 names.
|
||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||
|
||||
# Prefer modern WebKit package, fallback for older images.
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -98,10 +107,14 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install portaudio
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -132,6 +145,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Pack SDK and template packages
|
||||
shell: pwsh
|
||||
|
||||
3
.github/workflows/code-quality.yml
vendored
3
.github/workflows/code-quality.yml
vendored
@@ -25,12 +25,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
166
.github/workflows/ddss-publish.yml
vendored
Normal file
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
|
||||
619
.github/workflows/release.yml
vendored
619
.github/workflows/release.yml
vendored
@@ -30,14 +30,22 @@ jobs:
|
||||
informational_version: ${{ steps.version.outputs.informational_version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
release_channel: ${{ steps.version.outputs.release_channel }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository metadata
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get release info
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
CHECKOUT_REF="${GITHUB_REF}"
|
||||
IS_PRERELEASE="false"
|
||||
else
|
||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
||||
@@ -47,19 +55,40 @@ jobs:
|
||||
else
|
||||
TAG="v${RAW_TAG}"
|
||||
fi
|
||||
CHECKOUT_REF="${GITHUB_SHA}"
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
CHECKOUT_REF="refs/tags/${TAG}"
|
||||
else
|
||||
CHECKOUT_REF="${GITHUB_SHA}"
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
|
||||
IS_PRERELEASE="true"
|
||||
else
|
||||
IS_PRERELEASE="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
||||
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
||||
VERSION_PARTS+=("0")
|
||||
done
|
||||
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||
RELEASE_CHANNEL="preview"
|
||||
else
|
||||
RELEASE_CHANNEL="stable"
|
||||
fi
|
||||
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-windows:
|
||||
needs: prepare
|
||||
@@ -88,6 +117,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -106,9 +136,6 @@ jobs:
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
|
||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
||||
|
||||
# AOT 单文件发布
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||
-c Release `
|
||||
-o ./$launcherPublishDir `
|
||||
@@ -119,27 +146,16 @@ jobs:
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false
|
||||
-p:DebugSymbols=false `
|
||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Launcher AOT publish failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 显示发布结果
|
||||
Write-Host "Launcher published to: $launcherPublishDir"
|
||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
$size = [Math]::Round($exeFile.Length / 1MB, 2)
|
||||
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
||||
}
|
||||
|
||||
# 清理不必要的文件(AOT 单文件应该只有一个 exe)
|
||||
$files = Get-ChildItem -Path $launcherPublishDir -File
|
||||
if ($files.Count -gt 1) {
|
||||
Write-Host "Warning: Expected single file but found $($files.Count) files"
|
||||
$files | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish Main App
|
||||
@@ -177,9 +193,6 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
}
|
||||
|
||||
Write-Host "Published to: $publishDir"
|
||||
Write-Host "Self-contained: $selfContained"
|
||||
shell: pwsh
|
||||
|
||||
- name: Restructure for Launcher
|
||||
@@ -190,30 +203,18 @@ jobs:
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
$appDir = "app-$version"
|
||||
|
||||
Write-Host "Restructuring for Launcher mode..."
|
||||
Write-Host "Version: $version"
|
||||
Write-Host "Publish dir: $publishDir"
|
||||
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||
|
||||
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||
$appPath = Join-Path $newStructure $appDir
|
||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||
|
||||
$launcherSource = $launcherPublishDir
|
||||
if (Test-Path $launcherSource) {
|
||||
Write-Host "Copying Launcher to root..."
|
||||
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
|
||||
} else {
|
||||
Write-Warning "Launcher publish dir not found: $launcherSource"
|
||||
if (Test-Path $launcherPublishDir) {
|
||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||
|
||||
Write-Host "New directory structure:"
|
||||
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
|
||||
|
||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
@@ -229,60 +230,31 @@ jobs:
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$suffix = "${{ matrix.suffix }}"
|
||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$outputDir = "build-installer"
|
||||
|
||||
if (-not (Test-Path -Path $publishDir)) {
|
||||
Write-Error "Publish directory not found: $publishDir"
|
||||
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
|
||||
exit 1
|
||||
}
|
||||
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
if (-not (Test-Path -Path $installerScript)) {
|
||||
Write-Error "Installer script not found: $installerScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$isccPath = $null
|
||||
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
||||
if ($isccCommand) {
|
||||
$isccPath = $isccCommand.Source
|
||||
}
|
||||
|
||||
$candidatePaths = @(
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
||||
"$env:ChocolateyInstall\bin\ISCC.exe",
|
||||
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
|
||||
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
||||
)
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
|
||||
$isccPath = $candidatePaths | Select-Object -First 1
|
||||
if (-not $isccPath) {
|
||||
foreach ($candidate in $candidatePaths) {
|
||||
if ($candidate -and (Test-Path -Path $candidate)) {
|
||||
$isccPath = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isccPath) {
|
||||
Write-Host "ISCC.exe was not found in PATH or known locations."
|
||||
Write-Host "Checked locations:"
|
||||
$candidatePaths | ForEach-Object { Write-Host " - $_" }
|
||||
Write-Host "Chocolatey bin listing (if exists):"
|
||||
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
|
||||
Write-Error "Inno Setup compiler not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found Inno Setup at: $isccPath"
|
||||
|
||||
Write-Host "Building installer for Windows $arch with version $version..."
|
||||
if (-not (Test-Path $installerScript)) {
|
||||
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$publishDir = (Resolve-Path $publishDir).Path
|
||||
$outputDir = (Resolve-Path $outputDir).Path
|
||||
@@ -298,8 +270,6 @@ jobs:
|
||||
$installerScript
|
||||
)
|
||||
|
||||
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
|
||||
|
||||
& $isccPath @compileArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
||||
@@ -311,186 +281,53 @@ jobs:
|
||||
Write-Error "Failed to create installer"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Successfully created: $($installerFile.Name)"
|
||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||
shell: pwsh
|
||||
|
||||
- name: Create App Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = "delta-output"
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
# 创建 app-{version}-win-{arch}.zip 供后续版本作为旧版本对比
|
||||
$appZipPath = Join-Path $outputDir "app-$version-win-$arch.zip"
|
||||
Write-Host "Creating app-$version-win-$arch.zip..."
|
||||
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal
|
||||
|
||||
$sizeMB = [Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)
|
||||
Write-Host "Created app-$version-win-$arch.zip: $sizeMB MB"
|
||||
shell: pwsh
|
||||
|
||||
- name: Generate Delta Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = "delta-output"
|
||||
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
# --- Determine previous version and download its app package for diff ---
|
||||
$previousVersion = $null
|
||||
$previousAppPath = $null
|
||||
try {
|
||||
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
|
||||
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
|
||||
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
|
||||
if ($previousRelease) {
|
||||
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
|
||||
Write-Host "Previous release version: $previousVersion"
|
||||
|
||||
# 下载旧版本的 app-{version}-win-{arch}.zip
|
||||
$prevAppZip = $previousRelease.assets | Where-Object { $_.name -eq "app-$previousVersion-win-$arch.zip" } | Select-Object -First 1
|
||||
if ($prevAppZip) {
|
||||
Write-Host "Found app-$previousVersion-win-$arch.zip in previous release - downloading for diff..."
|
||||
$prevAppZipDest = Join-Path $outputDir "prev-app.zip"
|
||||
Invoke-WebRequest -Uri $prevAppZip.browser_download_url -OutFile $prevAppZipDest -Headers $headers
|
||||
|
||||
# 解压 app-{version}.zip
|
||||
$previousAppPath = Join-Path $outputDir "prev-app"
|
||||
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
|
||||
Expand-Archive -Path $prevAppZipDest -DestinationPath $previousAppPath -Force
|
||||
Remove-Item -Path $prevAppZipDest -Force -ErrorAction SilentlyContinue
|
||||
|
||||
if ($previousAppPath -and (Test-Path $previousAppPath)) {
|
||||
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
|
||||
Write-Host "Extracted $prevFileCount files from previous version for diff"
|
||||
}
|
||||
} else {
|
||||
Write-Host "No app-$previousVersion-win-$arch.zip found in previous release - will generate full package"
|
||||
Write-Host "This is expected for the first release after this fix."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Could not fetch previous release: $_"
|
||||
}
|
||||
|
||||
# --- Generate delta package using the script ---
|
||||
if ($previousAppPath -and (Test-Path $previousAppPath) -and $previousVersion) {
|
||||
Write-Host "Generating delta package from $previousVersion to $version..."
|
||||
& $scriptPath `
|
||||
-PreviousVersion $previousVersion `
|
||||
-CurrentVersion $version `
|
||||
-PreviousDir $previousAppPath `
|
||||
-CurrentDir $currentAppPath `
|
||||
-OutputDir $outputDir
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Generate-DeltaPackage.ps1 failed"
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-Host "No previous version available - generating full package..."
|
||||
# Generate a "full" delta package (all files as "add")
|
||||
& $scriptPath `
|
||||
-PreviousVersion "0.0.0" `
|
||||
-CurrentVersion $version `
|
||||
-PreviousDir $currentAppPath `
|
||||
-CurrentDir $currentAppPath `
|
||||
-OutputDir $outputDir
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Generate-DeltaPackage.ps1 failed"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Clean up previous version extraction
|
||||
if ($previousAppPath -and (Test-Path $previousAppPath)) {
|
||||
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Display results
|
||||
$updateZipPath = Join-Path $outputDir "update.zip"
|
||||
if (Test-Path $updateZipPath) {
|
||||
$sizeMB = [Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)
|
||||
Write-Host "Created update.zip: $sizeMB MB"
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Sign File Map
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
run: |
|
||||
$outputDir = "delta-output"
|
||||
$filesJsonPath = Join-Path $outputDir "files.json"
|
||||
$signaturePath = Join-Path $outputDir "files.json.sig"
|
||||
|
||||
if (-not (Test-Path $filesJsonPath)) {
|
||||
Write-Error "files.json not found at $filesJsonPath"
|
||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||
if (-not (Test-Path $payloadRoot)) {
|
||||
Write-Error "Payload root not found: $payloadRoot"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
|
||||
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
|
||||
exit 0
|
||||
$stageDir = Join-Path $PWD "payload-stage/windows-$arch"
|
||||
$releaseDir = Join-Path $PWD "release-assets"
|
||||
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
|
||||
|
||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
return
|
||||
}
|
||||
|
||||
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
|
||||
$destinationDir = Split-Path -Path $destination -Parent
|
||||
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
|
||||
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
}
|
||||
|
||||
$privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
|
||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
|
||||
|
||||
Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
public class RsaSigner {
|
||||
public static void Sign(string jsonPath, string keyPath, string sigPath) {
|
||||
var jsonBytes = File.ReadAllBytes(jsonPath);
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(keyPath));
|
||||
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
|
||||
}
|
||||
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
|
||||
if (Test-Path $payloadZip) {
|
||||
Remove-Item $payloadZip -Force
|
||||
}
|
||||
"@
|
||||
|
||||
[RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
|
||||
Remove-Item -Path $privateKeyPath -Force
|
||||
|
||||
Write-Host "Signed files.json -> files.json.sig"
|
||||
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Delta Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
- name: Upload Release Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-delta-windows-x64
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
delta-output/files.json
|
||||
delta-output/files.json.sig
|
||||
delta-output/update.zip
|
||||
delta-output/app-*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
|
||||
path: build-installer/*.exe
|
||||
release-assets/files-windows-${{ matrix.arch }}.zip
|
||||
build-installer/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
@@ -515,12 +352,17 @@ jobs:
|
||||
libx11-6 libxrandr2 libxinerama1 \
|
||||
libxi6 libxcursor1 libxext6 \
|
||||
libxrender1 libxkbcommon-x11-0 \
|
||||
clang zlib1g-dev
|
||||
clang zlib1g-dev zip rsync
|
||||
|
||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -535,8 +377,6 @@ jobs:
|
||||
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for Linux x64..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-linux-x64 \
|
||||
@@ -547,15 +387,11 @@ jobs:
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
-p:EnableCompressionInSingleFile=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-linux-x64"
|
||||
ls -lh ./publish/launcher-linux-x64/
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
@@ -582,25 +418,15 @@ jobs:
|
||||
appDir="app-$version"
|
||||
launcherDir="publish/launcher-linux-x64"
|
||||
|
||||
echo "Restructuring for Launcher mode..."
|
||||
echo "Version: $version"
|
||||
|
||||
mkdir -p "$publishDir"
|
||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||
|
||||
if [ -d "$launcherDir" ]; then
|
||||
echo "Copying Launcher to root..."
|
||||
cp -r "$launcherDir"/* "$publishDir/"
|
||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
||||
fi
|
||||
|
||||
touch "$publishDir/$appDir/.current"
|
||||
|
||||
echo "New directory structure:"
|
||||
find "$publishDir" -maxdepth 2 | head -50
|
||||
|
||||
rm -rf "$launcherDir"
|
||||
|
||||
- name: Package as DEB
|
||||
@@ -613,12 +439,6 @@ jobs:
|
||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
||||
|
||||
if [ ! -d "$source" ]; then
|
||||
echo "Error: Source directory not found: $source"
|
||||
ls -la publish/ || echo "publish directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "build-deb/DEBIAN"
|
||||
mkdir -p "build-deb/usr/local/bin"
|
||||
mkdir -p "build-deb/usr/share/applications"
|
||||
@@ -627,20 +447,6 @@ jobs:
|
||||
|
||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
||||
|
||||
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
|
||||
echo "DEB package contains $item_count files"
|
||||
|
||||
if [ "$item_count" -eq 0 ]; then
|
||||
echo "Error: DEB package is empty after copy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
|
||||
echo "Error: Linux desktop resources are missing"
|
||||
ls -la "LanMountainDesktop/packaging/linux" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||
@@ -664,9 +470,9 @@ jobs:
|
||||
printf '%s\n' "Package: $package_name"
|
||||
printf '%s\n' "Version: $package_version"
|
||||
printf '%s\n' "Architecture: $arch"
|
||||
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
|
||||
printf '%s\n' "Description: LanMountain Desktop Application"
|
||||
printf '%s\n' " A desktop application for LanMountain."
|
||||
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
|
||||
printf '%s\n' 'Description: LanMountain Desktop Application'
|
||||
printf '%s\n' ' A desktop application for LanMountain.'
|
||||
} > "build-deb/DEBIAN/control"
|
||||
|
||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
|
||||
@@ -675,26 +481,49 @@ jobs:
|
||||
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||
chmod 755 "build-deb/DEBIAN/postinst"
|
||||
|
||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
||||
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
|
||||
ls -lh "${package_name}_${package_version}_${arch}.deb"
|
||||
else
|
||||
echo "Error: Failed to build DEB package"
|
||||
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
|
||||
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
payload_root="publish/linux-x64/app-$version"
|
||||
release_dir="$PWD/release-assets"
|
||||
stage_dir="$PWD/payload-stage/linux-x64"
|
||||
|
||||
if [ ! -d "$payload_root" ]; then
|
||||
echo "Payload root not found: $payload_root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload
|
||||
rm -rf "$stage_dir"
|
||||
mkdir -p "$stage_dir" "$release_dir"
|
||||
rsync -a \
|
||||
--exclude '.current' \
|
||||
--exclude '.partial' \
|
||||
--exclude '.destroy' \
|
||||
"$payload_root/" "$stage_dir/"
|
||||
|
||||
(
|
||||
cd "$stage_dir"
|
||||
zip -qr "$release_dir/files-linux-x64.zip" .
|
||||
)
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux
|
||||
path: "*.deb"
|
||||
name: release-linux-x64
|
||||
path: |
|
||||
release-assets/files-linux-x64.zip
|
||||
*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
needs: prepare
|
||||
runs-on: macos-latest
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
name: Build_macOS_${{ matrix.arch }}
|
||||
@@ -707,10 +536,14 @@ jobs:
|
||||
submodules: recursive
|
||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install portaudio
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -725,8 +558,6 @@ jobs:
|
||||
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||
@@ -737,15 +568,11 @@ jobs:
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
-p:EnableCompressionInSingleFile=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
|
||||
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
@@ -765,7 +592,22 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
release_dir="$PWD/release-assets"
|
||||
stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}"
|
||||
payload_root="publish/macos-${{ matrix.arch }}-app"
|
||||
|
||||
rm -rf "$stage_dir"
|
||||
mkdir -p "$stage_dir" "$release_dir"
|
||||
rsync -a "$payload_root/" "$stage_dir/"
|
||||
(
|
||||
cd "$stage_dir"
|
||||
zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" .
|
||||
)
|
||||
|
||||
- name: Restructure and Package as DMG
|
||||
continue-on-error: true
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
arch="${{ matrix.arch }}"
|
||||
@@ -774,41 +616,19 @@ jobs:
|
||||
launcherDir="publish/launcher-macos-$arch"
|
||||
appSourceDir="publish/macos-$arch-app"
|
||||
|
||||
echo "Restructuring for Launcher mode..."
|
||||
echo "Version: $version"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||
|
||||
appDir="app-$version"
|
||||
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
||||
|
||||
if [ -d "$appSourceDir" ]; then
|
||||
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||
else
|
||||
echo "Error: Main app source directory not found: $appSourceDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||
if [ -d "$launcherDir" ]; then
|
||||
echo "Copying Launcher to root..."
|
||||
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
||||
fi
|
||||
|
||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/Resources"
|
||||
|
||||
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
|
||||
echo "App bundle contains $item_count files"
|
||||
|
||||
if [ "$item_count" -eq 0 ]; then
|
||||
echo "Error: App bundle is empty after copy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
@@ -832,105 +652,96 @@ jobs:
|
||||
|
||||
mkdir -p dmg-temp
|
||||
cp -r "${app_name}.app" dmg-temp/
|
||||
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
|
||||
|
||||
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
|
||||
echo "Successfully created: ${package_name}.dmg"
|
||||
ls -lh "${package_name}.dmg"
|
||||
else
|
||||
echo "Error: Failed to create DMG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf dmg-temp "${app_name}.app"
|
||||
|
||||
- name: Upload
|
||||
- name: Upload Release Assets
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos-${{ matrix.arch }}
|
||||
path: "*.dmg"
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
release-assets/files-macos-${{ matrix.arch }}.zip
|
||||
*.dmg
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
github-release:
|
||||
needs: [ prepare, build-windows, build-linux, build-macos ]
|
||||
needs: [prepare, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
path: release-files
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List artifacts structure
|
||||
- name: Normalize release files
|
||||
run: |
|
||||
echo "Artifact directory structure:"
|
||||
find artifacts -type f -o -type d | sort
|
||||
echo ""
|
||||
echo "Files found:"
|
||||
find artifacts -type f -exec ls -lh {} \;
|
||||
echo ""
|
||||
echo "Full tree:"
|
||||
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
|
||||
mkdir -p release-bundle
|
||||
|
||||
- name: Flatten artifacts for release
|
||||
run: |
|
||||
echo "Organizing artifacts..."
|
||||
mkdir -p release-files
|
||||
# Copy installers and packages
|
||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||
# Copy delta update files (files.json, files.json.sig, update.zip)
|
||||
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
|
||||
# Copy app package for future delta generation (app-{version}-win-{arch}.zip)
|
||||
find artifacts -type f -name "app-*.zip" -exec cp -v {} release-files/ \;
|
||||
echo ""
|
||||
echo "Files ready for release:"
|
||||
ls -lh release-files/ || echo "No files found in release-files"
|
||||
echo ""
|
||||
echo "Total files:"
|
||||
file_count=$(find release-files -type f | wc -l)
|
||||
echo "$file_count"
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "Error: No release files found"
|
||||
mapfile -t downloaded_files < <(find release-files -type f)
|
||||
if [ "${#downloaded_files[@]}" -eq 0 ]; then
|
||||
echo "No downloaded release artifacts were found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
for file in "${downloaded_files[@]}"; do
|
||||
base_name="$(basename "$file")"
|
||||
target_path="release-bundle/$base_name"
|
||||
|
||||
if [ -e "$target_path" ]; then
|
||||
echo "Duplicate release asset name detected: $base_name"
|
||||
echo "Conflicting file: $file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$file" "$target_path"
|
||||
done
|
||||
|
||||
- name: Validate release files
|
||||
run: |
|
||||
echo "Release files:"
|
||||
find release-bundle -maxdepth 1 -type f -exec ls -lh {} \;
|
||||
|
||||
if [ ! -f release-bundle/files-windows-x64.zip ] || [ ! -f release-bundle/files-windows-x86.zip ] || [ ! -f release-bundle/files-linux-x64.zip ]; then
|
||||
echo "Required payload zips are missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file_count=$(find release-bundle -maxdepth 1 -type f | wc -l)
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "No release files were produced."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create or Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
name: ${{ needs.prepare.outputs.tag }}
|
||||
commit: ${{ github.sha }}
|
||||
allowUpdates: true
|
||||
draft: false
|
||||
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
|
||||
artifacts: "release-files/**"
|
||||
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
artifacts: 'release-bundle/*'
|
||||
body: |
|
||||
## Release ${{ needs.prepare.outputs.version }}
|
||||
|
||||
### Windows
|
||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
|
||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
|
||||
### Installers
|
||||
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
|
||||
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
|
||||
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
|
||||
|
||||
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
|
||||
|
||||
Installation: Double-click the .exe file and follow the wizard.
|
||||
|
||||
### Incremental Update (Windows x64)
|
||||
- **files.json** - Update manifest listing changed files
|
||||
- **files.json.sig** - RSA signature of the manifest
|
||||
- **update.zip** - Archive containing changed files
|
||||
|
||||
Existing users: The app will automatically detect and apply the incremental update on next launch.
|
||||
|
||||
### Linux
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
||||
### Payload Archives
|
||||
- `files-windows-x64.zip`
|
||||
- `files-windows-x86.zip`
|
||||
- `files-linux-x64.zip`
|
||||
|
||||
### macOS
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
||||
- macOS assets are best-effort and will not block the release.
|
||||
|
||||
See commits for changes.
|
||||
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -512,3 +512,5 @@ nul
|
||||
/*.deb
|
||||
/*.dmg
|
||||
/*.AppImage
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
|
||||
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Checklist
|
||||
|
||||
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
|
||||
- [ ] `release.yml` uploads app payload artifacts for PDCC.
|
||||
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
|
||||
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
|
||||
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
|
||||
- [ ] Host can persist PDC payload into launcher incoming directory.
|
||||
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
|
||||
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
|
||||
- [ ] CI run attached proving all release matrix jobs pass.
|
||||
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
||||
- [ ] Rollback verification report attached.
|
||||
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# PDC Incremental Update Migration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
||||
|
||||
## Stage 1 (Completed)
|
||||
|
||||
- Release workflow removed VeloPack-based release packaging.
|
||||
- Signed FileMap path was restored as an interim release mechanism.
|
||||
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
|
||||
|
||||
## Stage 2 (Current Implementation Target)
|
||||
|
||||
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
|
||||
- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
|
||||
- Keep GitHub Release installers and metadata as parallel distribution.
|
||||
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
|
||||
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
|
||||
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
|
||||
|
||||
Expected S3 layout:
|
||||
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
|
||||
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
|
||||
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
|
||||
- `lanmountain/update/installers/<platform>/<arch>/*`
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `release.yml` includes PDCC publish steps and no Velopack steps.
|
||||
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
|
||||
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
|
||||
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
|
||||
- Launcher can apply both:
|
||||
- legacy signed `files.json + update.zip`
|
||||
- PDC FileMap object-repo payload.
|
||||
- Rollback semantics remain unchanged.
|
||||
|
||||
## Deprecated Notes
|
||||
|
||||
- The following interim outputs are compatibility-only (not the long-term primary path):
|
||||
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
||||
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
||||
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
||||
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Remove VeloPack packaging from release workflow.
|
||||
- [x] Keep signed FileMap path as interim compatibility fallback.
|
||||
- [x] Remove launcher/runtime Velopack branching.
|
||||
- [ ] Add `phainon.yml` for PDCC publish configuration.
|
||||
- [ ] Add PDCC installation + publish steps in `release.yml`.
|
||||
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
|
||||
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
|
||||
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
|
||||
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
|
||||
- [ ] Add PDC payload model into host update check result.
|
||||
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
|
||||
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
|
||||
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.
|
||||
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Checklist (Deprecated)
|
||||
|
||||
- [x] Spec marked as deprecated.
|
||||
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
|
||||
- [x] No release workflow dependency remains on VeloPack.
|
||||
15
.trae/specs/velopack-update-integration/spec.md
Normal file
15
.trae/specs/velopack-update-integration/spec.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# VeloPack Update Integration (Deprecated)
|
||||
|
||||
## Status
|
||||
|
||||
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
|
||||
|
||||
## Deprecation Reason
|
||||
|
||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
|
||||
## Migration Note
|
||||
|
||||
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.
|
||||
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Tasks (Deprecated)
|
||||
|
||||
- [x] Mark VeloPack integration spec as deprecated.
|
||||
- [x] Remove VeloPack runtime branches from launcher/host update path.
|
||||
- [x] Remove VeloPack release workflow packaging steps.
|
||||
- [ ] Keep archive for historical context only (no new implementation tasks here).
|
||||
@@ -214,14 +214,12 @@ public partial class App : Application
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
||||
|
||||
coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
||||
|
||||
28
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
28
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(SignedFileMap))]
|
||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
||||
[JsonSerializable(typeof(PlondsFileMap))]
|
||||
[JsonSerializable(typeof(PlondsComponentEntry))]
|
||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
||||
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||
[JsonSerializable(typeof(AppVersionInfo))]
|
||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||
[JsonSerializable(typeof(LauncherResult))]
|
||||
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
||||
[JsonSerializable(typeof(PluginManifest))]
|
||||
[JsonSerializable(typeof(PendingUpgrade))]
|
||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
@@ -1,8 +1,11 @@
|
||||
-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
|
||||
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
|
||||
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
|
||||
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
|
||||
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
|
||||
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
|
||||
-----END RSA PUBLIC KEY-----
|
||||
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
|
||||
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
|
||||
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
|
||||
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
|
||||
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
|
||||
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
|
||||
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
|
||||
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
|
||||
FQiGowgqx0l5AgMBAAE=
|
||||
-----END RSA PUBLIC KEY-----
|
||||
|
||||
@@ -56,7 +56,11 @@
|
||||
<!-- 允许 IL 警告 -->
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
|
||||
<!-- FluentAvaloniaUI 需要:启用反射序列化(AOT 兼容模式) -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
|
||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -53,3 +53,92 @@ internal sealed class UpdateApplyResult
|
||||
|
||||
public string? RolledBackTo { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PlondsUpdateMetadata
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? SubChannel { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? FileMapPath { get; set; }
|
||||
|
||||
public string? FileMapSignaturePath { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsFileMap
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PlondsComponentEntry> Components { get; set; } = [];
|
||||
|
||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsComponentEntry
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? Action { get; set; } = "replace";
|
||||
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string? ObjectUrl { get; set; }
|
||||
|
||||
public string? ObjectPath { get; set; }
|
||||
|
||||
public string? ObjectKey { get; set; }
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
public string? Sha512 { get; set; }
|
||||
|
||||
public string? Sha512Base64 { get; set; }
|
||||
|
||||
public byte[]? Sha512Bytes { get; set; }
|
||||
|
||||
public PlondsHashDescriptor? Hash { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsHashDescriptor
|
||||
{
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
public string? Value { get; set; }
|
||||
|
||||
public byte[]? Bytes { get; set; }
|
||||
}
|
||||
|
||||
@@ -91,11 +91,7 @@ internal static class Commands
|
||||
"check" => updateEngine.CheckPendingUpdate(),
|
||||
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
|
||||
"rollback" => updateEngine.RollbackLatest(),
|
||||
"download" => await updateEngine.DownloadAsync(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
||||
CancellationToken.None).ConfigureAwait(false),
|
||||
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
|
||||
_ => new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
@@ -106,6 +102,15 @@ internal static class Commands
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||
{
|
||||
return await updateEngine.DownloadAsync(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static LauncherResult ExecutePluginCommand(
|
||||
CommandContext context,
|
||||
PluginInstallerService pluginInstaller,
|
||||
@@ -149,10 +154,7 @@ internal static class Commands
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
|
||||
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ internal sealed class DeploymentLocator
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(snapshotFile);
|
||||
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json);
|
||||
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
|
||||
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
|
||||
{
|
||||
if (Directory.Exists(snapshot.SourceDirectory))
|
||||
@@ -445,7 +445,7 @@ internal sealed class DeploymentLocator
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFile);
|
||||
var info = JsonSerializer.Deserialize<AppVersionInfo>(json);
|
||||
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
|
||||
if (info is not null)
|
||||
{
|
||||
return info;
|
||||
|
||||
@@ -159,7 +159,7 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json);
|
||||
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
|
||||
if (config?.HostPath != null && File.Exists(config.HostPath))
|
||||
{
|
||||
return config.HostPath;
|
||||
@@ -617,13 +617,13 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
public required string AppRoot { get; set; }
|
||||
public required HostDiscoveryOptions Options { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发现配置文件
|
||||
/// </summary>
|
||||
private class HostDiscoveryConfig
|
||||
{
|
||||
public string? HostPath { get; set; }
|
||||
public List<string>? AdditionalPaths { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发现配置文件
|
||||
/// </summary>
|
||||
internal class HostDiscoveryConfig
|
||||
{
|
||||
public string? HostPath { get; set; }
|
||||
public List<string>? AdditionalPaths { get; set; }
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ public class LauncherIpcServer : IDisposable
|
||||
|
||||
// 3. 反序列化并回调
|
||||
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
|
||||
if (message is not null)
|
||||
{
|
||||
_onProgress(message);
|
||||
|
||||
@@ -9,11 +9,19 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
private readonly CommandContext _context;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly UpdateEngineService _updateEngine;
|
||||
private readonly UpdateCheckService _updateCheckService;
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
@@ -22,14 +30,12 @@ internal sealed class LauncherFlowCoordinator
|
||||
DeploymentLocator deploymentLocator,
|
||||
OobeStateService oobeStateService,
|
||||
UpdateEngineService updateEngine,
|
||||
UpdateCheckService updateCheckService,
|
||||
PluginInstallerService pluginInstallerService)
|
||||
{
|
||||
_context = context;
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_updateCheckService = updateCheckService;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||
}
|
||||
@@ -167,21 +173,31 @@ internal sealed class LauncherFlowCoordinator
|
||||
var processExitTask = hostProcess.WaitForExitAsync();
|
||||
|
||||
// 等待主程序就绪或进程退出(取先发生者)
|
||||
// 延长超时到 120 秒,给主程序足够的加载时间
|
||||
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
|
||||
var readyOrTimeoutOrExit = Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(120)));
|
||||
Task.Delay(TimeSpan.FromSeconds(30)));
|
||||
|
||||
var completedTask = await readyOrTimeoutOrExit;
|
||||
|
||||
// 检查是否是进程先退出(异常情况)
|
||||
// Host process exited before reporting Ready.
|
||||
if (completedTask == processExitTask)
|
||||
{
|
||||
var exitCode = hostProcess.ExitCode;
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited unexpectedly with code: {exitCode}");
|
||||
|
||||
// 关闭 Splash 窗口
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
|
||||
|
||||
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
|
||||
exitCode,
|
||||
hostReadyTcs,
|
||||
splashWindow,
|
||||
loadingDetailsWindow).ConfigureAwait(false);
|
||||
if (recoveryResult is not null)
|
||||
{
|
||||
return recoveryResult;
|
||||
}
|
||||
|
||||
// Close Splash window for unrecoverable early exits.
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
@@ -196,7 +212,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
@@ -279,6 +295,133 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LauncherResult?> TryRecoverFromEarlyHostExitAsync(
|
||||
int exitCode,
|
||||
TaskCompletionSource hostReadyTcs,
|
||||
SplashWindow splashWindow,
|
||||
LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Host redirected activation to an existing primary instance.");
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activated_existing_instance",
|
||||
Message = "Detected existing running instance and activation was acknowledged."
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode is not HostExitCodes.SecondaryActivationFailed and not HostExitCodes.RestartLockNotAcquired)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LauncherFlowCoordinator] Activation handshake failed with exit code {exitCode}. Retrying explicit activation once...");
|
||||
|
||||
var (retryLaunchResult, retryProcess) = await LaunchHostWithIpcAsync(splashWindow).ConfigureAwait(false);
|
||||
if (!retryLaunchResult.Success)
|
||||
{
|
||||
return retryLaunchResult;
|
||||
}
|
||||
|
||||
if (retryProcess is null)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_start_failed",
|
||||
Message = "Explicit activation retry failed because no host process was created."
|
||||
};
|
||||
}
|
||||
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] Explicit activation retry started. RetryPid={retryProcess.Id}.");
|
||||
var retryExitTask = retryProcess.WaitForExitAsync();
|
||||
var retryCompleted = await Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
retryExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
|
||||
|
||||
if (retryCompleted == hostReadyTcs.Task)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Host reported Ready after explicit activation retry.");
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_ready",
|
||||
Message = "Explicit activation retry succeeded and host reported Ready."
|
||||
};
|
||||
}
|
||||
|
||||
if (retryCompleted == retryExitTask)
|
||||
{
|
||||
var retryExitCode = retryProcess.ExitCode;
|
||||
if (retryExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_redirected",
|
||||
Message = "Explicit activation retry redirected to the existing primary instance."
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_failed",
|
||||
Message = $"Explicit activation retry failed. ExitCode={retryExitCode}. 请结束残留后台进程后重试。"
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_timeout",
|
||||
Message = "Explicit activation retry timed out before host became ready. 请结束残留后台进程后重试。"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close splash window: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||
{
|
||||
loadingDetailsWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close loading details window: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
|
||||
{
|
||||
// 优先使用自定义路径(调试模式选择的路径)
|
||||
@@ -315,36 +458,62 @@ internal sealed class LauncherFlowCoordinator
|
||||
EnsureExecutable(hostPath);
|
||||
}
|
||||
|
||||
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
|
||||
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
|
||||
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0),
|
||||
// 避免子进程窗口创建成功但不可见的问题。
|
||||
var arguments = new System.Text.StringBuilder();
|
||||
|
||||
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
|
||||
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid)
|
||||
foreach (var arg in _context.RawArgs)
|
||||
{
|
||||
if (arg == _context.Command || arg == _context.SubCommand)
|
||||
continue;
|
||||
|
||||
if (arg.StartsWith("--"))
|
||||
{
|
||||
var key = arg[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0) key = key[..equalsIndex];
|
||||
|
||||
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arguments.Length > 0) arguments.Append(' ');
|
||||
arguments.Append(QuoteArgument(arg));
|
||||
}
|
||||
|
||||
// 通过命令行参数传递 IPC 连接信息(UseShellExecute=true 时不支持 EnvironmentVariables)
|
||||
if (arguments.Length > 0) arguments.Append(' ');
|
||||
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
|
||||
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = hostWorkingDir,
|
||||
Arguments = arguments.ToString()
|
||||
};
|
||||
|
||||
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
|
||||
foreach (var arg in _context.RawArgs)
|
||||
{
|
||||
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
|
||||
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
processStartInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
// 传递环境变量供 IPC 使用
|
||||
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承)
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
|
||||
Environment.ProcessId.ToString();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
|
||||
_deploymentLocator.GetAppRoot();
|
||||
|
||||
// 传递版本信息
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
||||
|
||||
var hostProcess = Process.Start(processStartInfo);
|
||||
Console.WriteLine(
|
||||
$"[LauncherFlowCoordinator] Host launch requested. Path='{hostPath}'; WorkingDir='{hostWorkingDir}'; " +
|
||||
$"Pid={(hostProcess is null ? -1 : hostProcess.Id)}; Args='{processStartInfo.Arguments}'.");
|
||||
return (new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
@@ -483,6 +652,36 @@ internal sealed class LauncherFlowCoordinator
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
|
||||
@@ -73,7 +73,7 @@ internal sealed class PluginInstallerService
|
||||
using var stream = entries[0].Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
||||
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
|
||||
if (manifest == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
|
||||
|
||||
@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
}
|
||||
|
||||
var text = File.ReadAllText(pendingPath);
|
||||
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
|
||||
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
|
||||
var failures = new List<string>();
|
||||
var succeeded = new List<PendingUpgrade>();
|
||||
|
||||
@@ -63,10 +63,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
@@ -79,19 +76,19 @@ internal sealed class PluginUpgradeQueueService
|
||||
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt)
|
||||
internal sealed record PendingUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||
File.Exists(SourcePackagePath);
|
||||
}
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||
File.Exists(SourcePackagePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ internal sealed class UpdateCheckService
|
||||
private readonly string _repoOwner;
|
||||
private readonly string _repoName;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public UpdateCheckService(string repoOwner, string repoName)
|
||||
{
|
||||
@@ -24,12 +23,6 @@ internal sealed class UpdateCheckService
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +90,7 @@ internal sealed class UpdateCheckService
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
|
||||
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
|
||||
|
||||
return releases?.Select(r => new ReleaseInfo
|
||||
{
|
||||
@@ -131,38 +124,38 @@ internal sealed class UpdateCheckService
|
||||
var cleaned = ParseVersionString(versionString);
|
||||
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
// GitHub API 响应模型
|
||||
private sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("prerelease")]
|
||||
public bool Prerelease { get; set; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("assets")]
|
||||
public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("browser_download_url")]
|
||||
public string? BrowserDownloadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub API 响应模型
|
||||
internal sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("prerelease")]
|
||||
public bool Prerelease { get; set; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("assets")]
|
||||
public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("browser_download_url")]
|
||||
public string? BrowserDownloadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600"
|
||||
d:DesignHeight="500"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
|
||||
Title="阑山桌面 - 加载详情"
|
||||
Title="LanMountain Desktop - Loading Details"
|
||||
Width="600"
|
||||
Height="500"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
@@ -17,18 +18,17 @@
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
<!-- 标题栏 -->
|
||||
<Border Grid.Row="0"
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="正在启动阑山桌面"
|
||||
<TextBlock Text="Starting LanMountain Desktop"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
<TextBlock x:Name="SubtitleText"
|
||||
Text="初始化系统组件..."
|
||||
Text="Initializing..."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
@@ -46,7 +46,6 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<Grid Grid.Row="1" Margin="16,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
@@ -54,7 +53,6 @@
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 整体进度条 -->
|
||||
<ProgressBar x:Name="OverallProgressBar"
|
||||
Grid.Row="0"
|
||||
Height="8"
|
||||
@@ -64,14 +62,12 @@
|
||||
CornerRadius="4"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<!-- 当前活动项 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="16,12"
|
||||
Margin="0,0,0,12">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||
<!-- 图标 -->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
|
||||
Width="40"
|
||||
Height="40"
|
||||
@@ -88,23 +84,20 @@
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 名称 -->
|
||||
<TextBlock x:Name="CurrentItemName"
|
||||
Grid.Row="0" Grid.Column="1"
|
||||
Text="正在初始化..."
|
||||
Text="Initializing..."
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
|
||||
<!-- 描述 -->
|
||||
<TextBlock x:Name="CurrentItemDescription"
|
||||
Grid.Row="1" Grid.Column="1"
|
||||
Text="准备加载系统组件"
|
||||
Text="Preparing components"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
|
||||
<!-- 进度 -->
|
||||
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
|
||||
<ProgressBar x:Name="CurrentItemProgress"
|
||||
Height="4"
|
||||
@@ -116,15 +109,13 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 加载项列表 -->
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="8">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 列表标题 -->
|
||||
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="加载项"
|
||||
Text="Loading Items"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
@@ -135,22 +126,20 @@
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,0,4,0"/>
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="已完成"
|
||||
Text="Done"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<ScrollViewer Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Margin="8,0,8,8">
|
||||
<ItemsControl x:Name="LoadingItemsList">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
<DataTemplate DataType="views:LoadingItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
Margin="4,3"
|
||||
Opacity="{Binding Opacity}">
|
||||
<!-- 状态图标 -->
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding StatusIcon}"
|
||||
FontSize="14"
|
||||
@@ -159,7 +148,6 @@
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- 名称 -->
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Name}"
|
||||
FontSize="13"
|
||||
@@ -167,7 +155,6 @@
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- 进度 -->
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding ProgressText}"
|
||||
FontSize="12"
|
||||
@@ -175,7 +162,6 @@
|
||||
Margin="8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- 类型标签 -->
|
||||
<Border Grid.Column="3"
|
||||
Background="{Binding TypeBackground}"
|
||||
CornerRadius="4"
|
||||
@@ -194,7 +180,6 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- 错误信息区域 -->
|
||||
<Border x:Name="ErrorPanel"
|
||||
Grid.Row="2"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
@@ -214,14 +199,13 @@
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="ErrorText"
|
||||
Grid.Column="1"
|
||||
Text="加载过程中出现错误"
|
||||
Text="An error occurred while loading."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<Border Grid.Row="3"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="16,12">
|
||||
@@ -234,12 +218,12 @@
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="DetailsButton"
|
||||
Content="查看详情"
|
||||
Content="Details"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="CancelButton"
|
||||
Content="取消"
|
||||
Content="Cancel"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Standardized host process exit codes consumed by the launcher.
|
||||
/// </summary>
|
||||
public static class HostExitCodes
|
||||
{
|
||||
public const int Success = 0;
|
||||
|
||||
// Secondary instance activated the existing primary instance successfully.
|
||||
public const int SecondaryActivationSucceeded = 12;
|
||||
|
||||
// Secondary instance failed to activate the existing primary instance.
|
||||
public const int SecondaryActivationFailed = 13;
|
||||
|
||||
// Restart relaunch couldn't acquire the single-instance lock in time.
|
||||
public const int RestartLockNotAcquired = 14;
|
||||
}
|
||||
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal file
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SingleInstanceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TryNotifyPrimaryInstance_ReturnsTrue_WhenPrimaryAcknowledges()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var activated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
primary.StartActivationListener(() => activated.TrySetResult());
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
|
||||
Assert.True(acknowledged);
|
||||
Assert.Null(failureReason);
|
||||
|
||||
var completed = await Task.WhenAny(activated.Task, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(activated.Task, completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNotifyPrimaryInstance_ReturnsFalse_WhenListenerIsNotRunning()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromMilliseconds(300), out var failureReason);
|
||||
|
||||
Assert.False(acknowledged);
|
||||
Assert.False(string.IsNullOrWhiteSpace(failureReason));
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateService(string mutexName, string pipeName)
|
||||
{
|
||||
var ctor = typeof(SingleInstanceService).GetConstructor(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
[typeof(string), typeof(string)],
|
||||
modifiers: null);
|
||||
|
||||
Assert.NotNull(ctor);
|
||||
return (SingleInstanceService)ctor!.Invoke([mutexName, pipeName]);
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateSecondaryService(string mutexName, string pipeName)
|
||||
{
|
||||
SingleInstanceService? created = null;
|
||||
Exception? creationError = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
created = CreateService(mutexName, pipeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
creationError = ex;
|
||||
}
|
||||
});
|
||||
|
||||
thread.IsBackground = true;
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (creationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to create secondary SingleInstanceService.", creationError);
|
||||
}
|
||||
|
||||
Assert.NotNull(created);
|
||||
return created!;
|
||||
}
|
||||
|
||||
private static void MarkAsSecondaryForTest(SingleInstanceService service)
|
||||
{
|
||||
var ownsMutexField = typeof(SingleInstanceService).GetField(
|
||||
"_ownsMutex",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(ownsMutexField);
|
||||
ownsMutexField!.SetValue(service, false);
|
||||
Assert.False(service.IsPrimaryInstance);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ public partial class App : Application
|
||||
private LauncherIpcClient? _launcherIpcClient;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
private int _forcedExitScheduled;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
@@ -142,7 +144,7 @@ public partial class App : Application
|
||||
EnsureNotificationService();
|
||||
}
|
||||
|
||||
public override async void OnFrameworkInitializationCompleted()
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
@@ -152,12 +154,8 @@ public partial class App : Application
|
||||
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
|
||||
// 初始化 Launcher IPC 客户端(如果从 Launcher 启动)
|
||||
await InitializeLauncherIpcAsync();
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
|
||||
@@ -166,6 +164,10 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
|
||||
// 使用 fire-and-forget 模式,不阻塞主流程
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
}
|
||||
|
||||
private async Task InitializeLauncherIpcAsync()
|
||||
@@ -189,9 +191,10 @@ public partial class App : Application
|
||||
|
||||
// 注册系统初始化加载项
|
||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
|
||||
_loadingStateManager.StartItem("system.init", "正在连接启动器...");
|
||||
_loadingStateManager.StartItem("system.init", "已连接启动器");
|
||||
|
||||
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -227,7 +230,7 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步向 Launcher 报告启动进度,确保关键消息可靠送达
|
||||
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
|
||||
/// 用于 Ready 等关键状态报告
|
||||
/// </summary>
|
||||
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
|
||||
@@ -237,27 +240,27 @@ public partial class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
// 使用同步等待确保消息发送完成
|
||||
var task = _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
});
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// 等待最多 5 秒,确保消息发送成功
|
||||
if (!task.Wait(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", "Report progress timeout after 5 seconds");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress synchronously: {ex.Message}");
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,16 +292,20 @@ public partial class App : Application
|
||||
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
},
|
||||
() =>
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
},
|
||||
OnDesktopLifetimeExit,
|
||||
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||
StartWeatherLocationRefreshIfNeeded);
|
||||
_desktopShellHost.Initialize(this);
|
||||
}
|
||||
|
||||
private void OnDesktopLifetimeExit()
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
ReleaseSingleInstanceAfterExit("DesktopLifetimeExit");
|
||||
ScheduleForcedProcessTermination("DesktopLifetimeExit");
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||
@@ -658,70 +665,102 @@ public partial class App : Application
|
||||
|
||||
private void ActivateMainWindow()
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
|
||||
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
|
||||
|
||||
try
|
||||
{
|
||||
var restored = Dispatcher.UIThread.CheckAccess()
|
||||
? RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance")
|
||||
: Dispatcher.UIThread.InvokeAsync(
|
||||
() => RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance"),
|
||||
DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||
|
||||
if (!restored)
|
||||
{
|
||||
throw new InvalidOperationException("Main window restore failed in activation callback.");
|
||||
}
|
||||
|
||||
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Hide();
|
||||
}
|
||||
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.PrepareEnterAnimation();
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
mainWindow.PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
if (showSingleInstanceNotice)
|
||||
{
|
||||
mainWindow.ShowSingleInstanceNotice();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
|
||||
}
|
||||
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
||||
|
||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Hide();
|
||||
}
|
||||
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.PrepareEnterAnimation();
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
mainWindow.PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
if (showSingleInstanceNotice)
|
||||
{
|
||||
mainWindow.ShowSingleInstanceNotice();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTransparentOverlayWindow()
|
||||
{
|
||||
@@ -884,6 +923,57 @@ public partial class App : Application
|
||||
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void ReleaseSingleInstanceAfterExit(string source)
|
||||
{
|
||||
if (_singleInstanceReleased)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_singleInstanceReleased = true;
|
||||
var singleInstance = CurrentSingleInstanceService;
|
||||
CurrentSingleInstanceService = null;
|
||||
if (singleInstance is null)
|
||||
{
|
||||
AppLogger.Info("SingleInstance", $"No single-instance handle to release. Source='{source}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
singleInstance.Dispose();
|
||||
AppLogger.Info("SingleInstance", $"Released single-instance handle. Source='{source}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", $"Failed to release single-instance handle. Source='{source}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleForcedProcessTermination(string source)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _forcedExitScheduled, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(8)).ConfigureAwait(false);
|
||||
AppLogger.Warn(
|
||||
"DesktopShell",
|
||||
$"Process did not terminate after desktop exit cleanup. Forcing process exit. Source='{source}'; ShutdownIntent='{_shutdownIntent}'.");
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Forced process termination scheduler failed. Source='{source}'.", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void PerformExitCleanup()
|
||||
{
|
||||
if (_exitCleanupCompleted)
|
||||
@@ -934,6 +1024,22 @@ public partial class App : Application
|
||||
disposableRegistry.Dispose();
|
||||
}
|
||||
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_transparentOverlayWindow.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_transparentOverlayWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||
DisposeTrayIcon();
|
||||
@@ -979,6 +1085,33 @@ public partial class App : Application
|
||||
// 延迟报告 Ready 直到窗口实际打开并可见
|
||||
// 使用 Opened 事件确保所有资源已加载完毕
|
||||
mainWindow.Opened += OnMainWindowOpened;
|
||||
|
||||
// 手动显示窗口,因为在 ShutdownMode.OnExplicitShutdown 模式下框架不会自动调用 Show
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
|
||||
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.Ready,
|
||||
ProgressPercent = 100,
|
||||
Message = "就绪"
|
||||
});
|
||||
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
@@ -1132,11 +1265,9 @@ public partial class App : Application
|
||||
"DesktopShell",
|
||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
// 检查三指滑动功能是否启用
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
if (appSnapshot.EnableThreeFingerSwipe)
|
||||
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
|
||||
{
|
||||
// 显示透明覆盖层窗口
|
||||
EnsureTransparentOverlayWindow();
|
||||
_transparentOverlayWindow?.Show();
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||
|
||||
public string UpdateDownloadSource { get; set; } = "github";
|
||||
public string UpdateDownloadSource { get; set; } = "stcn";
|
||||
|
||||
public int UpdateDownloadThreads { get; set; } = 4;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -32,11 +33,26 @@ public sealed class Program
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
|
||||
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
||||
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
if (activationAcknowledged)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,12 +100,15 @@ public static class AppRestartService
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executablePath,
|
||||
UseShellExecute = false,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
|
||||
var args = new System.Text.StringBuilder();
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -122,13 +125,16 @@ public static class AppRestartService
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = dotnetHostPath,
|
||||
UseShellExecute = false,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
// UseShellExecute=true 时使用 Arguments 字符串
|
||||
var args = new System.Text.StringBuilder();
|
||||
args.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -145,11 +151,61 @@ public static class AppRestartService
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append(QuoteArgument(commandLineArgs[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
|
||||
{
|
||||
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
|
||||
{
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
|
||||
{
|
||||
processId = 0;
|
||||
|
||||
@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
|
||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||
{
|
||||
// 已存在,可能只更新位置或尺寸
|
||||
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
|
||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
||||
if (existingWindow.IsVisible == false)
|
||||
{
|
||||
existingWindow.Show();
|
||||
|
||||
@@ -34,7 +34,20 @@ public sealed record UpdateCheckResult(
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage,
|
||||
bool ForceMode = false);
|
||||
bool ForceMode = false,
|
||||
PlondsUpdatePayload? PlondsPayload = null);
|
||||
|
||||
public sealed record PlondsUpdatePayload(
|
||||
string DistributionId,
|
||||
string ChannelId,
|
||||
string SubChannel,
|
||||
string? FileMapJson,
|
||||
string? FileMapSignature,
|
||||
string? FileMapJsonUrl,
|
||||
string? FileMapSignatureUrl,
|
||||
string? UpdateArchiveUrl = null,
|
||||
string? UpdateArchiveSha256 = null,
|
||||
long? UpdateArchiveSizeBytes = null);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
@@ -149,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
var preferredAsset = isUpdateAvailable
|
||||
? SelectPreferredInstallerAsset(release.Assets)
|
||||
: null;
|
||||
var plondsPayload = isUpdateAvailable
|
||||
? TryResolvePlondsPayload(release)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
@@ -157,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null);
|
||||
ErrorMessage: null,
|
||||
PlondsPayload: plondsPayload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -222,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
@@ -231,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: true);
|
||||
ForceMode: true,
|
||||
PlondsPayload: plondsPayload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -642,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
||||
if (assets is null || assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -654,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
var ranked = assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreLinuxInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreMacInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||
{
|
||||
if (release.Assets is null || release.Assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var platformSuffix = GetPlatformAssetSuffix();
|
||||
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||
var channelId = release.IsPrerelease
|
||||
? UpdateSettingsValues.ChannelPreview
|
||||
: UpdateSettingsValues.ChannelStable;
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: platformSuffix,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||
{
|
||||
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
|
||||
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
|
||||
@@ -709,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int ScoreLinuxInstallerAsset(string assetName, string architectureToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetName))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (assetName.EndsWith(".deb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 220;
|
||||
}
|
||||
else if (assetName.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 180;
|
||||
}
|
||||
else if (assetName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 160;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (assetName.Contains("linux", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase) ||
|
||||
(architectureToken == "x64" && assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int ScoreMacInstallerAsset(string assetName, string architectureToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetName))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (assetName.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 220;
|
||||
}
|
||||
else if (assetName.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 180;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (assetName.Contains("mac", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("osx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version? version)
|
||||
{
|
||||
version = null;
|
||||
|
||||
@@ -17,6 +17,11 @@ public class LauncherIpcClient : IDisposable
|
||||
private bool _isConnected;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否已连接到 Launcher
|
||||
/// </summary>
|
||||
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
|
||||
|
||||
/// <summary>
|
||||
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||
/// </summary>
|
||||
@@ -92,11 +97,28 @@ public class LauncherIpcClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否从 Launcher 启动
|
||||
/// 优先检查环境变量,回退到命令行参数(UseShellExecute=true 时环境变量仍可继承,
|
||||
/// 命令行参数作为备选确保兼容性)
|
||||
/// </summary>
|
||||
public static bool IsLaunchedByLauncher()
|
||||
{
|
||||
return !string.IsNullOrEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
|
||||
// 优先检查环境变量
|
||||
if (!string.IsNullOrEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>)
|
||||
foreach (var arg in Environment.GetCommandLineArgs())
|
||||
{
|
||||
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Timers;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
|
||||
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);
|
||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
|
||||
@@ -751,7 +751,8 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
|
||||
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
|
||||
|
||||
public UpdateSettingsService(ISettingsService settingsService)
|
||||
{
|
||||
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
@@ -838,7 +839,19 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return result.Success ? result.PlondsPayload : null;
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
@@ -849,7 +862,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.DownloadAssetAsync(
|
||||
return _githubReleaseUpdateService.DownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
@@ -866,7 +879,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.RedownloadAssetAsync(
|
||||
return _githubReleaseUpdateService.RedownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
@@ -877,7 +890,55 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_releaseUpdateService.Dispose();
|
||||
_githubReleaseUpdateService.Dispose();
|
||||
_plondsReleaseUpdateService.Dispose();
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
|
||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var plondsResult = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (plondsResult.Success)
|
||||
{
|
||||
return plondsResult;
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||
|
||||
var githubFallbackResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (githubFallbackResult.Success)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
return githubFallbackResult;
|
||||
}
|
||||
|
||||
return isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,14 +1290,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string GetAppVersionText()
|
||||
{
|
||||
// 优先从环境变量读取(Launcher 传递)
|
||||
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envVersion))
|
||||
{
|
||||
return envVersion;
|
||||
}
|
||||
|
||||
// 回退:从程序集读取
|
||||
// Fallback: read from application assembly.
|
||||
var assembly = typeof(App).Assembly;
|
||||
var informationalVersion = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
@@ -1276,14 +1337,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string GetAppCodenameText()
|
||||
{
|
||||
// 优先从环境变量读取(Launcher 传递)
|
||||
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envCodename))
|
||||
{
|
||||
return envCodename;
|
||||
}
|
||||
|
||||
// 回退:使用默认开发代号
|
||||
// Fallback: use default codename.
|
||||
return DefaultCodename;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class SingleInstanceService : IDisposable
|
||||
{
|
||||
private const byte ActivationRequestCode = 0x41; // 'A'
|
||||
private const byte ActivationAckCode = 0x4B; // 'K'
|
||||
private const byte ActivationNackCode = 0x4E; // 'N'
|
||||
|
||||
private readonly Mutex _mutex;
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _listenCts = new();
|
||||
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"SingleInstance",
|
||||
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
|
||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||
{
|
||||
return TryNotifyPrimaryInstance(timeout, out _);
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
|
||||
{
|
||||
if (_ownsMutex || _disposed)
|
||||
{
|
||||
failureReason = _ownsMutex
|
||||
? "current_instance_is_primary"
|
||||
: "single_instance_service_disposed";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
|
||||
using var client = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: _pipeName,
|
||||
direction: PipeDirection.Out,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
||||
client.WriteByte(1);
|
||||
client.WriteByte(ActivationRequestCode);
|
||||
client.Flush();
|
||||
|
||||
var ack = client.ReadByte();
|
||||
var acknowledged = ack == ActivationAckCode;
|
||||
if (!acknowledged)
|
||||
{
|
||||
failureReason = ack switch
|
||||
{
|
||||
ActivationNackCode => "primary_rejected_activation",
|
||||
-1 => "ack_not_received",
|
||||
_ => $"unexpected_ack_code_{ack}"
|
||||
};
|
||||
AppLogger.Warn(
|
||||
"SingleInstance",
|
||||
$"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
failureReason = null;
|
||||
AppLogger.Info(
|
||||
"SingleInstance",
|
||||
$"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failureReason = "primary_activation_handshake_exception";
|
||||
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
||||
return false;
|
||||
}
|
||||
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.In,
|
||||
PipeDirection.InOut,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
|
||||
onActivationRequested();
|
||||
var buffer = new byte[1];
|
||||
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
|
||||
var ackCode = ActivationAckCode;
|
||||
|
||||
if (!isActivationRequest)
|
||||
{
|
||||
ackCode = ActivationNackCode;
|
||||
AppLogger.Warn(
|
||||
"SingleInstance",
|
||||
$"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
onActivationRequested();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ackCode = ActivationNackCode;
|
||||
AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var ackBuffer = new[] { ackCode };
|
||||
await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
|
||||
await server.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,12 @@ public static class UpdateSettingsValues
|
||||
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
||||
public const string ModeSilentOnExit = "silent_on_exit";
|
||||
|
||||
// NOTE: keep constant name for compatibility with existing call sites.
|
||||
public const string DownloadSourcePlonds = "stcn";
|
||||
public const string DownloadSourcePdc = DownloadSourcePlonds;
|
||||
public const string DownloadSourceStcn = DownloadSourcePlonds;
|
||||
public const string LegacyDownloadSourcePlonds = "pdc";
|
||||
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
|
||||
public const string DownloadSourceGitHub = "github";
|
||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||
|
||||
@@ -51,9 +57,28 @@ public static class UpdateSettingsValues
|
||||
|
||||
public static string NormalizeDownloadSource(string? value)
|
||||
{
|
||||
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
|
||||
? DownloadSourceGhProxy
|
||||
: DownloadSourceGitHub;
|
||||
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceStcn;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourcePlonds;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceGhProxy;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceGitHub;
|
||||
}
|
||||
|
||||
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
|
||||
return DownloadSourceStcn;
|
||||
}
|
||||
|
||||
public static int NormalizeDownloadThreads(int value)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
|
||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _previewChannelText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pdcSourceText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _gitHubSourceText = string.Empty;
|
||||
|
||||
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
public bool IsPreviewChannelSelected =>
|
||||
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPdcSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGitHubSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectPdcSource()
|
||||
{
|
||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectGitHubSource()
|
||||
{
|
||||
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
||||
: L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
? L("settings.update.status_force_checking", "Force checking update source...")
|
||||
: L("settings.update.status_checking", "Checking update source...");
|
||||
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||
_lastCheckResult = result.Success ? result : null;
|
||||
@@ -1953,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.PreferredAsset is null)
|
||||
if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
|
||||
{
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
|
||||
@@ -2038,7 +2050,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
|
||||
private async Task RedownloadUpdateAsync()
|
||||
{
|
||||
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
|
||||
if (_lastCheckResult is null ||
|
||||
!_lastCheckResult.Success ||
|
||||
!_lastCheckResult.IsUpdateAvailable ||
|
||||
(_lastCheckResult.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(_lastCheckResult)))
|
||||
{
|
||||
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
|
||||
return;
|
||||
@@ -2100,7 +2115,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
|
||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||
@@ -2112,6 +2127,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
||||
StableChannelText = L("settings.update.channel_stable", "Stable");
|
||||
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
||||
PdcSourceText = L("settings.update.source_pdc", "PDC");
|
||||
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
||||
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
||||
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
||||
@@ -2220,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
UpdateDownloadResult downloadResult;
|
||||
|
||||
// Prefer delta update if available (smaller download, faster)
|
||||
if (result.Release is not null && UpdateWorkflowService.IsDeltaUpdateAvailable(result.Release))
|
||||
if (UpdateWorkflowService.IsDeltaUpdateAvailable(result))
|
||||
{
|
||||
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
|
||||
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
|
||||
if (!downloadResult.Success)
|
||||
if (!downloadResult.Success && result.PlondsPayload is null)
|
||||
{
|
||||
// Delta download failed, fall back to full installer
|
||||
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");
|
||||
@@ -2309,6 +2325,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
|
||||
{
|
||||
UpdateSettingsValues.DownloadSourcePdc => L(
|
||||
"settings.update.source_pdc_desc",
|
||||
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
|
||||
UpdateSettingsValues.DownloadSourceGhProxy => L(
|
||||
"settings.update.source_ghproxy_desc",
|
||||
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
||||
@@ -2360,6 +2379,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
||||
];
|
||||
|
||||
@@ -25,6 +25,23 @@ public partial class DesktopWidgetWindow : Window
|
||||
ComponentContainer.Child = componentContent;
|
||||
}
|
||||
|
||||
public void UpdateComponentLayout(double width, double height)
|
||||
{
|
||||
ComponentContainer.Width = width;
|
||||
ComponentContainer.Height = height;
|
||||
|
||||
if (ComponentContainer.Child is Control child)
|
||||
{
|
||||
child.Width = width;
|
||||
child.Height = height;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows() && IsVisible)
|
||||
{
|
||||
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace LanMountainDesktop.Views;
|
||||
public partial class TransparentOverlayWindow : Window
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||
|
||||
// 滑动状态
|
||||
private bool _isSwipeActive;
|
||||
@@ -77,6 +79,11 @@ public partial class TransparentOverlayWindow : Window
|
||||
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = facade.Region.GetTimeZoneService();
|
||||
_settingsFacade = facade;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_bottomMostService.SetupBottomMost(this);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
@@ -84,6 +91,7 @@ public partial class TransparentOverlayWindow : Window
|
||||
public void SaveLayoutAndHide()
|
||||
{
|
||||
SaveLayout();
|
||||
_regionPassthroughService.ClearInteractiveRegions(this);
|
||||
Hide();
|
||||
|
||||
// Remove all components so that next time we open it builds fresh from snapshot
|
||||
@@ -131,6 +139,11 @@ public partial class TransparentOverlayWindow : Window
|
||||
RenderAllComponents();
|
||||
|
||||
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_bottomMostService.SendToBottom(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window
|
||||
/// </summary>
|
||||
private void UpdateInteractiveRegions()
|
||||
{
|
||||
// 编辑模式下不再需要底层穿透功能计算,这里留空或移除
|
||||
_interactiveRegions.Clear();
|
||||
|
||||
foreach (var host in _componentHosts.Values)
|
||||
{
|
||||
var left = Canvas.GetLeft(host);
|
||||
var top = Canvas.GetTop(host);
|
||||
var width = host.Width > 0 ? host.Width : host.Bounds.Width;
|
||||
var height = host.Height > 0 ? host.Height : host.Bounds.Height;
|
||||
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 稍微向外扩一圈,确保拖拽和右下角缩放手柄也能命中。
|
||||
_interactiveRegions.Add(new Rect(left - 12, top - 12, width + 24, height + 24));
|
||||
}
|
||||
|
||||
_regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- 明确指定不需要管理员权限,以调用者权限运行 -->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<!-- Windows 10/11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
@@ -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>]");
|
||||
}
|
||||
}
|
||||
@@ -194,3 +194,9 @@ This repository is organized around a desktop host app plus a host-side plugin e
|
||||
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
|
||||
|
||||
## VeloPack Integration Note
|
||||
|
||||
- Incremental package build/publish has moved to VeloPack native assets (
|
||||
eleases.win.json + *.nupkg).
|
||||
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
|
||||
|
||||
@@ -166,3 +166,10 @@ Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is
|
||||
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
|
||||
|
||||
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
## VeloPack Release Assets
|
||||
|
||||
- Windows incremental release packaging now uses VeloPack native outputs (
|
||||
eleases.win.json, *.nupkg).
|
||||
- Launcher still performs update apply/rollback; VeloPack is used for package generation.
|
||||
- Legacy delta script flow is retained behind a disabled fallback switch in CI.
|
||||
|
||||
@@ -442,3 +442,10 @@ private static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
- [Launcher 架构文档](LAUNCHER.md)
|
||||
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
||||
- [故障排除指南](TROUBLESHOOTING.md)
|
||||
|
||||
## VeloPack Packaging (Current)
|
||||
|
||||
- Release pipeline now produces VeloPack native assets (
|
||||
eleases.win.json, *.nupkg, RELEASES).
|
||||
- Launcher remains the installer and rollback authority; only package generation moved to VeloPack.
|
||||
- Legacy iles.json + update.zip generation remains available only as a disabled fallback path in CI.
|
||||
|
||||
32
phainon.yml
Normal file
32
phainon.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Phainon Distribution Center Client Configuration
|
||||
name: "LanMountainDesktop"
|
||||
components:
|
||||
app:
|
||||
allowDiffUpdate: true
|
||||
root: "app-$(version)/"
|
||||
includes:
|
||||
- "**"
|
||||
launcher:
|
||||
root: ""
|
||||
includes:
|
||||
- "**"
|
||||
excludes:
|
||||
- "app*/**"
|
||||
- "files*.json"
|
||||
- "files*.json.sig"
|
||||
- "update*.zip"
|
||||
variables:
|
||||
number: 0
|
||||
fileRepoRoot: "__FILE_REPO_ROOT__"
|
||||
archiveRoot: "__ARCHIVE_ROOT__/$(primaryVersion)/$(version)/"
|
||||
bucketKeyRoot: "lanmountain/update/repo/"
|
||||
archiveBucketKeyRoot: "lanmountain/update/archive/$(primaryVersion)/$(version)/"
|
||||
appChangeLogPath: "$(thisFileDir)/../CHANGELOG.md"
|
||||
appChangeLogTemplate: |
|
||||
$(changeLog)
|
||||
|
||||
---
|
||||
|
||||
## Checksums And Downloads
|
||||
|
||||
$(hashes)
|
||||
@@ -1,105 +1,165 @@
|
||||
# Generate-DeltaPackage.ps1
|
||||
# 生成增量更新包 (delta.zip + files.json)
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PreviousVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PreviousDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
|
||||
Write-Host "从版本: $PreviousVersion"
|
||||
Write-Host "到版本: $CurrentVersion"
|
||||
Write-Host "上一版本目录: $PreviousDir"
|
||||
Write-Host "当前版本目录: $CurrentDir"
|
||||
Write-Host "输出目录: $OutputDir"
|
||||
Write-Host ""
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
# 确保输出目录存在
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
function Get-NormalizedRelativePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootDir,
|
||||
|
||||
# 计算文件 SHA256
|
||||
function Get-FileSha256 {
|
||||
param([string]$Path)
|
||||
$hash = Get-FileHash -Path $Path -Algorithm SHA256
|
||||
return $hash.Hash.ToLower()
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FullPath
|
||||
)
|
||||
|
||||
$separator = [System.IO.Path]::DirectorySeparatorChar
|
||||
$altSeparator = [System.IO.Path]::AltDirectorySeparatorChar
|
||||
|
||||
$root = [System.IO.Path]::GetFullPath($RootDir).Replace($altSeparator, $separator).TrimEnd($separator)
|
||||
$path = [System.IO.Path]::GetFullPath($FullPath).Replace($altSeparator, $separator)
|
||||
|
||||
$comparison = if ($separator -eq '\') {
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
}
|
||||
else {
|
||||
[System.StringComparison]::Ordinal
|
||||
}
|
||||
|
||||
$rootWithSeparator = "$root$separator"
|
||||
if ($path.StartsWith($rootWithSeparator, $comparison)) {
|
||||
$relative = $path.Substring($rootWithSeparator.Length)
|
||||
}
|
||||
elseif ($path.Equals($root, $comparison)) {
|
||||
$relative = ""
|
||||
}
|
||||
else {
|
||||
throw "File path '$path' is not under root '$root'."
|
||||
}
|
||||
|
||||
return $relative.Replace('\', '/')
|
||||
}
|
||||
|
||||
function Get-FileSha256Hex {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
# 获取目录中所有文件的相对路径和哈希
|
||||
function Get-FileManifest {
|
||||
param([string]$RootDir)
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootDir
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RootDir)) {
|
||||
throw "Directory does not exist: $RootDir"
|
||||
}
|
||||
|
||||
$resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path
|
||||
$manifest = @{}
|
||||
$files = Get-ChildItem -Path $RootDir -Recurse -File
|
||||
|
||||
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
|
||||
$relativePath = $relativePath.Replace('\', '/')
|
||||
|
||||
$manifest[$relativePath] = @{
|
||||
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
|
||||
$manifest[$relativePath] = [ordered]@{
|
||||
Path = $relativePath
|
||||
Sha256 = Get-FileSha256 -Path $file.FullName
|
||||
Size = $file.Length
|
||||
Sha256 = Get-FileSha256Hex -Path $file.FullName
|
||||
Size = [long]$file.Length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $manifest
|
||||
}
|
||||
|
||||
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
|
||||
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
|
||||
if (-not (Test-Path $PreviousDir)) {
|
||||
throw "Previous directory does not exist: $PreviousDir"
|
||||
function New-DeltaArchive {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ZipPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentRoot,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]]$ChangedFiles = @()
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $ZipPath) {
|
||||
Remove-Item -LiteralPath $ZipPath -Force
|
||||
}
|
||||
|
||||
$zip = [System.IO.Compression.ZipFile]::Open($ZipPath, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($file in $ChangedFiles) {
|
||||
$sourcePath = Join-Path $CurrentRoot $file.Path
|
||||
if (-not (Test-Path -LiteralPath $sourcePath)) {
|
||||
throw "Changed file was not found while building archive: $sourcePath"
|
||||
}
|
||||
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
||||
$zip,
|
||||
$sourcePath,
|
||||
$file.Path,
|
||||
[System.IO.Compression.CompressionLevel]::Optimal
|
||||
) | Out-Null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$zip.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Generating incremental package..."
|
||||
Write-Host "From: $PreviousVersion"
|
||||
Write-Host "To: $CurrentVersion"
|
||||
Write-Host "Prev: $PreviousDir"
|
||||
Write-Host "Curr: $CurrentDir"
|
||||
Write-Host "Out: $OutputDir"
|
||||
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
$previousManifest = Get-FileManifest -RootDir $PreviousDir
|
||||
Write-Host " 找到 $($previousManifest.Count) 个文件" -ForegroundColor Gray
|
||||
|
||||
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
|
||||
Write-Host " 目录: $CurrentDir" -ForegroundColor Gray
|
||||
if (-not (Test-Path $CurrentDir)) {
|
||||
throw "Current directory does not exist: $CurrentDir"
|
||||
}
|
||||
$currentManifest = Get-FileManifest -RootDir $CurrentDir
|
||||
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
|
||||
|
||||
# 分析文件变更
|
||||
$changedFiles = @()
|
||||
$reusedFiles = @()
|
||||
$deletedFiles = @()
|
||||
|
||||
Write-Host "分析文件变更..." -ForegroundColor Yellow
|
||||
|
||||
# 检查新增和修改的文件
|
||||
foreach ($path in $currentManifest.Keys) {
|
||||
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
|
||||
$currentFile = $currentManifest[$path]
|
||||
|
||||
|
||||
if ($previousManifest.ContainsKey($path)) {
|
||||
$previousFile = $previousManifest[$path]
|
||||
|
||||
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
||||
# 文件未变更,可以复用
|
||||
$reusedFiles += @{
|
||||
$reusedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "reuse"
|
||||
Sha256 = $currentFile.Sha256
|
||||
Size = $currentFile.Size
|
||||
}
|
||||
} else {
|
||||
# 文件已修改
|
||||
$changedFiles += @{
|
||||
}
|
||||
else {
|
||||
$changedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "replace"
|
||||
Sha256 = $currentFile.Sha256
|
||||
@@ -107,9 +167,9 @@ foreach ($path in $currentManifest.Keys) {
|
||||
ArchivePath = $path
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# 新增文件
|
||||
$changedFiles += @{
|
||||
}
|
||||
else {
|
||||
$changedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "add"
|
||||
Sha256 = $currentFile.Sha256
|
||||
@@ -119,104 +179,51 @@ foreach ($path in $currentManifest.Keys) {
|
||||
}
|
||||
}
|
||||
|
||||
# 检查删除的文件
|
||||
foreach ($path in $previousManifest.Keys) {
|
||||
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
|
||||
if (-not $currentManifest.ContainsKey($path)) {
|
||||
$deletedFiles += @{
|
||||
$deletedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "变更统计:" -ForegroundColor Green
|
||||
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
|
||||
Write-Host " 复用: $($reusedFiles.Count) 个文件"
|
||||
Write-Host " 删除: $($deletedFiles.Count) 个文件"
|
||||
Write-Host ""
|
||||
Write-Host "Changed: $($changedFiles.Count)"
|
||||
Write-Host "Reused: $($reusedFiles.Count)"
|
||||
Write-Host "Deleted: $($deletedFiles.Count)"
|
||||
|
||||
# 显示前10个变更的文件(用于调试)
|
||||
if ($changedFiles.Count -gt 0) {
|
||||
Write-Host "变更的文件示例:" -ForegroundColor Cyan
|
||||
$changedFiles | Select-Object -First 10 | ForEach-Object {
|
||||
Write-Host " [$($_.Action)] $($_.Path)" -ForegroundColor Gray
|
||||
}
|
||||
if ($changedFiles.Count -gt 10) {
|
||||
Write-Host " ... 还有 $($changedFiles.Count - 10) 个文件" -ForegroundColor Gray
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# 创建临时目录用于打包
|
||||
$tempDir = Join-Path $OutputDir "temp_delta"
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
|
||||
|
||||
# 复制变更的文件到临时目录
|
||||
Write-Host "复制变更文件..." -ForegroundColor Yellow
|
||||
foreach ($file in $changedFiles) {
|
||||
$sourcePath = Join-Path $CurrentDir $file.Path
|
||||
$destPath = Join-Path $tempDir $file.Path
|
||||
$destDir = Split-Path -Parent $destPath
|
||||
|
||||
if (-not (Test-Path $destDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $sourcePath -Destination $destPath -Force
|
||||
}
|
||||
|
||||
# 创建 update.zip (Launcher 期望的文件名)
|
||||
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
|
||||
$updateZipPath = Join-Path $OutputDir "update.zip"
|
||||
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
|
||||
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
|
||||
|
||||
if (Test-Path $updateZipPath) {
|
||||
Remove-Item -Path $updateZipPath -Force
|
||||
}
|
||||
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
|
||||
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
|
||||
|
||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
|
||||
|
||||
# 同时创建带版本号的副本(用于发布到 GitHub Release)
|
||||
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
|
||||
Write-Host "创建带版本号的副本: $deltaZipPath" -ForegroundColor Yellow
|
||||
if (Test-Path $deltaZipPath) {
|
||||
Remove-Item -Path $deltaZipPath -Force
|
||||
}
|
||||
Copy-Item -Path $updateZipPath -Destination $deltaZipPath -Force
|
||||
|
||||
# 清理临时目录
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
|
||||
# 生成 files.json (Launcher 期望的文件名)
|
||||
$filesJson = @{
|
||||
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||
$filesJson = [ordered]@{
|
||||
FromVersion = $PreviousVersion
|
||||
ToVersion = $CurrentVersion
|
||||
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Files = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
|
||||
Files = $allEntries
|
||||
}
|
||||
|
||||
$jsonText = $filesJson | ConvertTo-Json -Depth 10
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
|
||||
$filesJsonPath = Join-Path $OutputDir "files.json"
|
||||
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
|
||||
[System.IO.File]::WriteAllText($filesJsonPath, $jsonText, $utf8NoBom)
|
||||
|
||||
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
|
||||
$versionedFilesJsonPath = Join-Path $OutputDir ("files-{0}.json" -f $CurrentVersion)
|
||||
Copy-Item -LiteralPath $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
||||
|
||||
# 同时创建带版本号的副本(用于发布到 GitHub Release)
|
||||
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
|
||||
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
|
||||
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
||||
|
||||
# 计算增量包大小
|
||||
$updateSize = (Get-Item $updateZipPath).Length
|
||||
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
|
||||
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
|
||||
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
||||
Write-Host "增量包大小: $updateSizeMB MB"
|
||||
Write-Host "输出文件 (Launcher 使用):"
|
||||
Write-Host " - $updateZipPath"
|
||||
Write-Host " - $filesJsonPath"
|
||||
Write-Host "输出文件 (GitHub Release 发布):"
|
||||
Write-Host " - $deltaZipPath"
|
||||
Write-Host " - $versionedFilesJsonPath"
|
||||
Write-Host "Done."
|
||||
Write-Host "update.zip size: $updateSizeMb MB"
|
||||
Write-Host "Generated:"
|
||||
Write-Host " $updateZipPath"
|
||||
Write-Host " $filesJsonPath"
|
||||
Write-Host " $deltaZipPath"
|
||||
Write-Host " $versionedFilesJsonPath"
|
||||
|
||||
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."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user