mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8403b89a15 | ||
|
|
0ea98c08bf | ||
|
|
54d97e312d | ||
|
|
04b95020bd | ||
|
|
cf08269e15 | ||
|
|
03e4442e74 | ||
|
|
0c8830133a | ||
|
|
131043fe37 |
146
.github/workflows/plonds-rollback.yml
vendored
146
.github/workflows/plonds-rollback.yml
vendored
@@ -1,146 +0,0 @@
|
||||
name: PLONDS Rollback
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: 'Target channel to rollback'
|
||||
required: true
|
||||
type: choice
|
||||
default: stable
|
||||
options:
|
||||
- stable
|
||||
- preview
|
||||
target_tag:
|
||||
description: 'Release tag to rollback to (e.g. v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: plonds-rollback-${{ github.event.inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve rollback context
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
RAW_TAG="${{ github.event.inputs.target_tag }}"
|
||||
if [[ "$RAW_TAG" == v* ]]; then
|
||||
TAG="$RAW_TAG"
|
||||
else
|
||||
TAG="v$RAW_TAG"
|
||||
fi
|
||||
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate rollback target assets
|
||||
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 name in plonds.json plonds.json.sig; do
|
||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
done
|
||||
|
||||
- name: Build rollback pointer
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p rollback-output
|
||||
pointer_file="rollback-output/plonds-latest.json"
|
||||
|
||||
manifest_url="${S3_BASE_URL}/plonds.json"
|
||||
sig_url="${S3_BASE_URL}/plonds.json.sig"
|
||||
version="${RELEASE_TAG#v}"
|
||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
cat > "$pointer_file" <<EOF
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"channel": "${RELEASE_CHANNEL}",
|
||||
"releaseTag": "${RELEASE_TAG}",
|
||||
"version": "${version}",
|
||||
"updatedAt": "${updated_at}",
|
||||
"manifest": {
|
||||
"url": "${manifest_url}",
|
||||
"signatureUrl": "${sig_url}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
jq -e . "$pointer_file" >/dev/null
|
||||
|
||||
- name: Publish rollback pointer
|
||||
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
|
||||
|
||||
pointer_file="rollback-output/plonds-latest.json"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
|
||||
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
|
||||
|
||||
- name: Print rollback summary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
|
||||
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json"
|
||||
343
.github/workflows/plonds-uploader.yml
vendored
343
.github/workflows/plonds-uploader.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: PLONDS Publisher
|
||||
name: PLONDS Publisher
|
||||
|
||||
concurrency:
|
||||
group: plonds-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||
group: plonds-publish-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
@@ -19,11 +19,18 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
PLONDS_S3_PREFIX: lanmountain/update/plonds
|
||||
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
|
||||
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
|
||||
PLONDS_S3_MULTIPART_THRESHOLD_MB: '8'
|
||||
PLONDS_S3_MULTIPART_PART_SIZE_MB: '5'
|
||||
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
@@ -35,7 +42,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve release tag and channel
|
||||
- name: Resolve release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
@@ -53,22 +60,8 @@ jobs:
|
||||
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
||||
fi
|
||||
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
CHANNEL="preview"
|
||||
else
|
||||
CHANNEL="stable"
|
||||
fi
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -76,304 +69,70 @@ jobs:
|
||||
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 }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; 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
|
||||
- name: Download PLONDS 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
|
||||
rm -rf plonds-assets
|
||||
mkdir -p plonds-assets
|
||||
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -p files-windows-x64.zip -D plonds-assets --clobber
|
||||
test -f plonds-assets/changed.zip
|
||||
test -f plonds-assets/PLONDS.json
|
||||
test -f plonds-assets/files-windows-x64.zip
|
||||
jq -e . plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Prepare PLONDS static output
|
||||
- name: Publish PLONDS assets to Rainyun S3
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf plonds-static
|
||||
mkdir -p plonds-static
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
|
||||
unzip -q release-assets/plonds-static.zip -d plonds-static
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
|
||||
echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
|
||||
if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
|
||||
echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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" == "plonds.json" || "$name" == "plonds.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
|
||||
REGION="${S3_REGION:-us-east-1}"
|
||||
PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
|
||||
- name: Upload PLONDS static output 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 --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
|
||||
plonds-static/ \
|
||||
"s3://$S3_BUCKET/lanmountain/update/" \
|
||||
--only-show-errors
|
||||
|
||||
- name: Mirror installers 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
|
||||
version="${RELEASE_TAG#v}"
|
||||
for file in release-assets/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
name="$(basename "$file")"
|
||||
platform=""
|
||||
case "$name" in
|
||||
*.exe)
|
||||
if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
|
||||
;;
|
||||
*.deb)
|
||||
platform="linux-x64"
|
||||
;;
|
||||
*.dmg)
|
||||
if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
|
||||
;;
|
||||
esac
|
||||
[[ -n "$platform" ]] || continue
|
||||
key="lanmountain/update/installers/${platform}/${version}/${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
|
||||
|
||||
- name: Build PLONDS manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p plonds-output
|
||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||
build-plonds \
|
||||
publish-s3 \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--assets-dir release-assets \
|
||||
--output-dir plonds-output \
|
||||
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--s3-base-url "$S3_BASE_URL"
|
||||
--manifest "$PWD/plonds-assets/PLONDS.json" \
|
||||
--changed-zip "$PWD/plonds-assets/changed.zip" \
|
||||
--files-zip "$PWD/plonds-assets/files-windows-x64.zip" \
|
||||
--work-dir "$PWD/plonds-publish-work" \
|
||||
--s3-prefix "$PLONDS_S3_PREFIX" \
|
||||
--s3-endpoint "$S3_ENDPOINT" \
|
||||
--s3-region "$REGION" \
|
||||
--s3-bucket "$S3_BUCKET" \
|
||||
--s3-access-key "$S3_ACCESS_KEY" \
|
||||
--s3-secret-key "$S3_SECRET_KEY" \
|
||||
--s3-public-base-url "$PUBLIC_BASE" \
|
||||
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
|
||||
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY" \
|
||||
--multipart-threshold-mb "$PLONDS_S3_MULTIPART_THRESHOLD_MB" \
|
||||
--multipart-part-size-mb "$PLONDS_S3_MULTIPART_PART_SIZE_MB" \
|
||||
--multipart-concurrency "$PLONDS_S3_MULTIPART_CONCURRENCY"
|
||||
|
||||
- name: Validate PLONDS asset references in 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
|
||||
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' plonds-output/plonds.json \
|
||||
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
|
||||
| sort -u)
|
||||
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
if [[ -z "$keys" ]]; then
|
||||
echo "No S3-backed asset URLs found in plonds.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r key; do
|
||||
[[ -n "$key" ]] || continue
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
done <<< "$keys"
|
||||
|
||||
- name: Upload PLONDS manifest to release
|
||||
- name: Upload enriched PLONDS manifest to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber
|
||||
|
||||
- name: Upload PLONDS manifest to Rainyun S3 staging
|
||||
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 plonds-output/plonds.json plonds-output/plonds.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
|
||||
|
||||
- name: Prepare PLONDS channel pointer
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pointer_file="plonds-output/plonds-latest.json"
|
||||
cat > "$pointer_file" <<'JSON'
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"channel": "__CHANNEL__",
|
||||
"releaseTag": "__TAG__",
|
||||
"version": "__VERSION__",
|
||||
"updatedAt": "__UPDATED_AT__",
|
||||
"manifest": {
|
||||
"url": "__MANIFEST_URL__",
|
||||
"signatureUrl": "__SIG_URL__"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
manifest_url="${S3_BASE_URL}/plonds.json"
|
||||
sig_url="${S3_BASE_URL}/plonds.json.sig"
|
||||
version="${RELEASE_TAG#v}"
|
||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file"
|
||||
sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file"
|
||||
sed -i "s|__VERSION__|${version}|g" "$pointer_file"
|
||||
sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file"
|
||||
sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file"
|
||||
sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file"
|
||||
|
||||
jq -e . "$pointer_file" >/dev/null
|
||||
|
||||
- name: Atomically publish PLONDS channel pointer
|
||||
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
|
||||
pointer_file="plonds-output/plonds-latest.json"
|
||||
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-latest.json"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$staging_key" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
|
||||
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
|
||||
|
||||
- name: Verify Rainyun S3 PLONDS output
|
||||
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
|
||||
mapfile -t required < <(
|
||||
{
|
||||
find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
|
||||
find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
|
||||
find plonds-static/repo/sha256 -type f | sort | head -n 1
|
||||
} | sed '/^$/d'
|
||||
)
|
||||
|
||||
if [[ "${#required[@]}" -lt 5 ]]; then
|
||||
echo "Not enough PLONDS static files to verify."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for path in "${required[@]}"; do
|
||||
rel="${path#plonds-static/}"
|
||||
key="lanmountain/update/${rel}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
|
||||
done
|
||||
gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --clobber
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -360,7 +360,7 @@ jobs:
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||
$payloadRoot = Join-Path $PWD "publish/windows-$arch"
|
||||
if (-not (Test-Path $payloadRoot)) {
|
||||
Write-Error "Payload root not found: $payloadRoot"
|
||||
exit 1
|
||||
@@ -374,7 +374,7 @@ jobs:
|
||||
|
||||
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/')) {
|
||||
if ($relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
174
.trae/specs/plonds-client-service/spec.md
Normal file
174
.trae/specs/plonds-client-service/spec.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# PLONDS Client Service 独立化设计
|
||||
|
||||
> 日期:2026-06-01
|
||||
> 状态:设计中
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PLONDS 在应用内必须作为独立服务存在,负责分发发现、下载、校验和本地包准备。它不是现有 Update 模块的 provider,也不应把 S3/GitHub/source 选择逻辑混入 `LanMountainDesktop/Services/Update/`。
|
||||
|
||||
最终边界:
|
||||
|
||||
- PLONDS 服务:寻找最新版本、选择下载源、下载 manifest 和包、校验文件、准备本地 staging。
|
||||
- 安装程序/安装网关:只消费 PLONDS 已准备好的本地安装输入,执行增量安装或完整安装。
|
||||
- UI:只展示 PLONDS 服务和安装程序返回的状态;完整包也失败后才处理错误。
|
||||
|
||||
## 2. 当前耦合点
|
||||
|
||||
当前需要拆离的耦合点:
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||
- 直接持有 `PlondsStaticUpdateService` 与 `PlondsReleaseUpdateService`
|
||||
- 在 `CheckForUpdatesCoreAsync` 中把 PLONDS 和 GitHub Update fallback 逻辑混在一起
|
||||
- `LanMountainDesktop/Services/Update/UpdateInstallGateway.cs`
|
||||
- 直接判断 `UpdatePayloadKind.DeltaPlonds`
|
||||
- 直接实例化 `PlondsUpdateApplier`
|
||||
- `LanMountainDesktop/Services/Update/Plonds*.cs`
|
||||
- PLONDS apply/parser/payload resolver 仍位于 Update 命名空间
|
||||
|
||||
## 3. Source 发现规则
|
||||
|
||||
PLONDS 客户端内置两个初始地址:
|
||||
|
||||
1. S3 上的 PLONDS manifest 地址
|
||||
2. GitHub Release 上的 PLONDS manifest 地址
|
||||
|
||||
两个地址读取的是同一种 JSON 文件,当前文件名为 `PLONDS.json`。客户端每次检查增量更新时,会并行或顺序请求所有已知 source 的 `PLONDS.json`。
|
||||
|
||||
### 3.1 Source 扩展
|
||||
|
||||
`PLONDS.json` 可以声明额外 source。客户端读取到额外 source 后,应把它们加入下一轮寻找列表。
|
||||
|
||||
建议 manifest 扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "rainyun-s3",
|
||||
"kind": "s3",
|
||||
"manifestUrl": "https://example.com/plonds/1.2.3/PLONDS.json",
|
||||
"priority": 100
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"kind": "github",
|
||||
"manifestUrl": "https://github.com/owner/repo/releases/download/v1.2.3/PLONDS.json",
|
||||
"priority": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `sources` 为空或缺失时,只使用内置 S3 + GitHub。
|
||||
- 新 source 不覆盖内置 source,除非 `id` 相同。
|
||||
- source 列表需要去重,按 `id` 和 `manifestUrl` 双重去重。
|
||||
- source 持久化到 PLONDS 自己的配置/缓存,不写入 Update 设置。
|
||||
|
||||
## 4. 版本选择规则
|
||||
|
||||
如果多个 source 返回的版本不一致,客户端选择 `currentVersion` 最高的 manifest。
|
||||
|
||||
规则:
|
||||
|
||||
- 版本解析使用 `Version` 语义,忽略前导 `v`。
|
||||
- 版本相同时,优先选择下载可用性更高的 source。
|
||||
- 如果最高版本 manifest 下载包失败,可以尝试同版本的其他 source。
|
||||
- 不因为低版本 source 成功而降级,除非用户显式允许。
|
||||
|
||||
## 5. 下载与回退规则
|
||||
|
||||
PLONDS 服务优先走增量包:
|
||||
|
||||
1. 下载所选 manifest。
|
||||
2. 下载 `changed.zip`。
|
||||
3. 校验 `changed.zip` 与 manifest 中的 hash/checksum。
|
||||
4. 解压或准备增量 staging。
|
||||
5. 交给安装程序执行增量安装。
|
||||
|
||||
如果增量流程失败,PLONDS 服务自动改用完整包:
|
||||
|
||||
1. 下载 `Files.zip`。
|
||||
2. 校验 `Files.zip`。
|
||||
3. 解压或准备完整包 staging。
|
||||
4. 交给安装程序执行完整包安装。
|
||||
|
||||
如果完整包也失败,PLONDS 服务返回失败结果,由 UI 展示错误和重试入口。
|
||||
|
||||
## 6. 发布产物布局
|
||||
|
||||
Publisher 上传到 S3 的版本目录:
|
||||
|
||||
```text
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
||||
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
||||
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
|
||||
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
||||
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||
- Publisher 必须先完成版本目录内的 `changed.zip`、`Files.zip`、解压目录和版本 `PLONDS.json` 上传,再更新 `<prefix>/PLONDS.json` latest 指针。
|
||||
- Publisher 的 S3 目录上传必须支持重跑续传;同 key 且大小一致的对象可以跳过,避免失败后从头上传完整包目录。
|
||||
- Publisher 上传大对象时应使用 S3 multipart upload,以避免 `changed.zip` / `Files.zip` 在低吞吐链路上被单次 PUT 长时间阻塞。
|
||||
|
||||
## 7. 建议代码结构
|
||||
|
||||
```text
|
||||
LanMountainDesktop/Services/Plonds/
|
||||
IPlondsService.cs
|
||||
PlondsService.cs
|
||||
Sources/
|
||||
IPlondsSource.cs
|
||||
PlondsHttpManifestSource.cs
|
||||
PlondsSourceRegistry.cs
|
||||
Download/
|
||||
PlondsDownloader.cs
|
||||
PlondsDownloadPlanner.cs
|
||||
Verification/
|
||||
PlondsVerifier.cs
|
||||
Staging/
|
||||
PlondsPackageStore.cs
|
||||
PlondsPreparedPackage.cs
|
||||
Models/
|
||||
PlondsClientManifest.cs
|
||||
PlondsSourceDescriptor.cs
|
||||
PlondsCheckResult.cs
|
||||
```
|
||||
|
||||
后续如果要移植,优先把这棵目录或等价项目抽成独立库。
|
||||
|
||||
## 8. 与安装程序的交接契约
|
||||
|
||||
PLONDS 服务输出本地 prepared package:
|
||||
|
||||
```csharp
|
||||
public sealed record PlondsPreparedPackage(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string ManifestPath,
|
||||
string? ChangedZipPath,
|
||||
string? ChangedDirectory,
|
||||
string? FilesZipPath,
|
||||
string? FilesDirectory);
|
||||
```
|
||||
|
||||
安装程序只接受这个结果,不参与 source 发现、下载和校验。
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
1. Publisher 补齐完整包 S3 上传与 manifest downloads 字段。
|
||||
2. 新增 `Services/Plonds/` 客户端服务骨架和模型。
|
||||
3. 把 `PlondsStaticUpdateService` / `PlondsReleaseUpdateService` 合并迁移到独立 PLONDS source 体系。
|
||||
4. 把 `LanMountainDesktop/Services/Update/Plonds*.cs` 迁出 Update 命名空间。
|
||||
5. `UpdateSettingsService` 改为调用 `IPlondsService`,不再直接组合 S3/GitHub PLONDS fallback。
|
||||
6. 安装入口只接收 `PlondsPreparedPackage`。
|
||||
7. 添加单元测试覆盖 source 扩展、最高版本选择、增量失败转完整包、完整包失败交 UI。
|
||||
@@ -250,7 +250,8 @@ public sealed record PlondsChangedFileEntry(
|
||||
### 6.1 保留两个工作流
|
||||
|
||||
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
||||
- **Publisher**(`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
|
||||
- **Publisher**(`plonds-uploader.yml`):发布器,负责用仓库内 C# S3 客户端上传 `changed.zip`、`PLONDS.json` 和解压后的 `<version>-changed/` 目录,并把 GitHub/S3 下载信息写回 `PLONDS.json`
|
||||
- **Rollback**:独立 rollback 工作流已废弃,不再维护
|
||||
|
||||
### 6.2 Comparator 改造后步骤
|
||||
|
||||
@@ -297,7 +298,43 @@ jobs:
|
||||
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
||||
```
|
||||
|
||||
### 6.3 与当前步骤的差异
|
||||
### 6.3 Publisher 改造后步骤
|
||||
|
||||
```yaml
|
||||
# plonds-uploader.yml
|
||||
触发: PLONDS Comparator completed / workflow_dispatch
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- Checkout
|
||||
- 解析 release tag
|
||||
- Setup .NET
|
||||
- 构建 PLONDS Tool
|
||||
- 从 GitHub Release 下载 changed.zip + PLONDS.json
|
||||
- 调用 dotnet run Plonds.Tool -- publish-s3
|
||||
→ 使用仓库内 C# S3 客户端上传,不依赖 aws CLI
|
||||
→ S3 目录布局:
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
→ 回写 PLONDS.json downloads 字段:
|
||||
downloads.github.releaseUrl
|
||||
downloads.github.manifestUrl
|
||||
downloads.github.changedZipUrl
|
||||
downloads.github.filesZipUrl
|
||||
downloads.s3.manifestUrl
|
||||
downloads.s3.changedZipUrl
|
||||
downloads.s3.changedFolderUrl
|
||||
downloads.s3.filesZipUrl
|
||||
downloads.s3.filesFolderUrl
|
||||
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
|
||||
```
|
||||
|
||||
### 6.4 与当前步骤的差异
|
||||
|
||||
| 当前步骤 | 改造后 |
|
||||
|---------|--------|
|
||||
@@ -307,6 +344,8 @@ jobs:
|
||||
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
|
||||
| 独立 rollback workflow | ❌ 删除 |
|
||||
|
||||
## 7. 双模式差分生成
|
||||
|
||||
@@ -504,9 +543,7 @@ build-delta-from-commits --platform <platform>
|
||||
|
||||
## 8. 不在本次改造范围内的事项
|
||||
|
||||
- Publisher 工作流改造(后续单独设计)
|
||||
- Rollback 工作流改造(后续单独设计)
|
||||
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||
- Launcher 侧客户端代码改造(后续单独设计)
|
||||
- Plonds.Api 项目处置(后续决定是否保留)
|
||||
- `build-index`、`build-plonds`、`generate`、`publish`、`sign`、`pack-payload` 等 Tool 命令的清理(后续处理)
|
||||
- `build-index`、`generate`、`publish`、`sign` 等旧 Tool 命令的清理(后续处理)
|
||||
|
||||
1125
CODE_WIKI.md
1125
CODE_WIKI.md
File diff suppressed because it is too large
Load Diff
711
LanMountainDesktop.Tests/PlondsClientServiceTests.cs
Normal file
711
LanMountainDesktop.Tests/PlondsClientServiceTests.cs
Normal file
@@ -0,0 +1,711 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.IO.Compression;
|
||||
using LanMountainDesktop.Services.Plonds;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PlondsClientServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.Tests",
|
||||
nameof(PlondsClientServiceTests),
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceRegistry_AddRange_DeduplicatesAndAllowsManifestExtensions()
|
||||
{
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
|
||||
registry.AddRange(
|
||||
[
|
||||
new("mirror", "http", "https://mirror.test/PLONDS.json", 10),
|
||||
new("s3", "s3", "https://s3-new.test/PLONDS.json", 200),
|
||||
new("duplicate-url", "http", "https://mirror.test/PLONDS.json", 1)
|
||||
]);
|
||||
|
||||
Assert.Equal(3, registry.Sources.Count);
|
||||
Assert.Contains(registry.Sources, source => source.Id == "s3" && source.ManifestUrl == "https://s3-new.test/PLONDS.json");
|
||||
Assert.Contains(registry.Sources, source => source.Id == "mirror");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestSelector_WhenVersionsDiffer_SelectsHighestVersion()
|
||||
{
|
||||
var selected = PlondsManifestSelector.SelectHighestVersion(
|
||||
[
|
||||
new(new("s3", "s3", "https://s3.test/PLONDS.json", 100), CreateManifest("1.2.0")),
|
||||
new(new("github", "github", "https://github.test/PLONDS.json", 50), CreateManifest("1.3.0")),
|
||||
new(new("mirror", "http", "https://mirror.test/PLONDS.json", 500), CreateManifest("1.1.9"))
|
||||
]);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal("1.3.0", selected.Manifest.CurrentVersion);
|
||||
Assert.Equal("github", selected.Source.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaFails_FallsBackToFullPackage()
|
||||
{
|
||||
var downloader = new FakeDownloader(deltaFails: true, fullFails: false);
|
||||
var planner = new PlondsDownloadPlanner(downloader);
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/PLONDS.json"), CreateManifest("1.2.3")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.RequiresUiHandling);
|
||||
Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode);
|
||||
Assert.Equal(1, downloader.DeltaCalls);
|
||||
Assert.Equal(1, downloader.FullCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaAndFullFail_ReturnsUiFailure()
|
||||
{
|
||||
var downloader = new FakeDownloader(deltaFails: true, fullFails: true);
|
||||
var planner = new PlondsDownloadPlanner(downloader);
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/PLONDS.json"), CreateManifest("1.2.3")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.RequiresUiHandling);
|
||||
Assert.Null(result.Package);
|
||||
Assert.Contains("full package fallback also failed", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenManifestRequiresCleanInstall_DoesNotPreparePlondsPackage()
|
||||
{
|
||||
var downloader = new FakeDownloader(deltaFails: false, fullFails: false);
|
||||
var planner = new PlondsDownloadPlanner(downloader);
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json"),
|
||||
CreateManifest("1.2.3", requiresCleanInstall: true)),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.RequiresUiHandling);
|
||||
Assert.Contains("clean install", result.ErrorMessage);
|
||||
Assert.Equal(0, downloader.DeltaCalls);
|
||||
Assert.Equal(0, downloader.FullCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_ReadsBuiltInSources_RegistersManifestSources_AndPreparesHighestVersion()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(new Dictionary<string, string>
|
||||
{
|
||||
["https://s3.test/PLONDS.json"] = ManifestJson("1.2.0", """
|
||||
"sources": [
|
||||
{ "id": "mirror", "kind": "http", "manifestUrl": "https://mirror.test/PLONDS.json", "priority": 25 }
|
||||
]
|
||||
"""),
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.3.0")
|
||||
}));
|
||||
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var downloader = new FakeDownloader(deltaFails: false, fullFails: false);
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(downloader));
|
||||
|
||||
var result = await service.FindAndPrepareLatestAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("1.3.0", result.Package?.Version.ToString());
|
||||
Assert.Equal(PlondsPackageMode.Delta, result.Package?.Mode);
|
||||
Assert.Contains(registry.Sources, source => source.Id == "mirror" && source.ManifestUrl == "https://mirror.test/PLONDS.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientServiceFactory_CreatesBuiltInS3AndGitHubSources()
|
||||
{
|
||||
var sources = PlondsClientServiceFactory.CreateBuiltInSources();
|
||||
|
||||
Assert.Equal(2, sources.Count);
|
||||
Assert.Contains(sources, source => source.Id == "s3" && source.Kind == "s3" && source.ManifestUrl.EndsWith("/PLONDS.json", StringComparison.Ordinal));
|
||||
Assert.Contains(sources, source => source.Id == "github" && source.Kind == "github" && source.ManifestUrl.EndsWith("/PLONDS.json", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_FindLatest_UsesHighestVersionAndPersistsManifestSources()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(new Dictionary<string, string>
|
||||
{
|
||||
["https://s3.test/PLONDS.json"] = ManifestJson("1.5.0", """
|
||||
"sources": [
|
||||
{ "id": "mirror", "kind": "http", "manifestUrl": "https://mirror.test/PLONDS.json", "priority": 25 }
|
||||
]
|
||||
"""),
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.4.0")
|
||||
}));
|
||||
var sourceStorePath = Path.Combine(_tempRoot, "sources.json");
|
||||
var sourceStore = new PlondsSourceStore(sourceStorePath);
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(new FakeDownloader(deltaFails: false, fullFails: false)),
|
||||
sourceStore);
|
||||
|
||||
var result = await service.FindLatestAsync(new Version(1, 4, 0), CancellationToken.None);
|
||||
var storedSources = await sourceStore.LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsUpdateAvailable);
|
||||
Assert.Equal("1.5.0", result.LatestVersion?.ToString());
|
||||
Assert.Contains(storedSources, source => source.Id == "mirror" && source.ManifestUrl == "https://mirror.test/PLONDS.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_WhenHighestVersionSourcePackageFails_TriesSameVersionOtherSource()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(new Dictionary<string, string>
|
||||
{
|
||||
["https://s3.test/PLONDS.json"] = ManifestJson("1.6.0"),
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.6.0")
|
||||
}));
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var downloader = new SourceAwareFakeDownloader(failingSourceId: "s3");
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(downloader));
|
||||
|
||||
var result = await service.FindAndPrepareLatestAsync(new Version(1, 5, 0), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("github", downloader.SuccessfulSourceId);
|
||||
Assert.Equal(2, downloader.DeltaCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_WhenManifestSourceThrows_ContinuesWithOtherSources()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.7.0")
|
||||
},
|
||||
throwingUrls: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"https://s3.test/PLONDS.json"
|
||||
}));
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(new FakeDownloader(deltaFails: false, fullFails: false)));
|
||||
|
||||
var result = await service.FindLatestAsync(new Version(1, 6, 0), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsUpdateAvailable);
|
||||
Assert.Equal("1.7.0", result.LatestVersion?.ToString());
|
||||
Assert.Equal("github", Assert.Single(result.Candidates).Source.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpDownloader_DownloadsVerifiesAndExtractsDeltaPackage()
|
||||
{
|
||||
var changedZip = CreateZip(("app.dll", "delta payload"));
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.0",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: "https://s3.test/1.4.0/changed.zip",
|
||||
filesUrl: "https://s3.test/1.4.0/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = Md5Checksum(changedZip),
|
||||
["Files.zip"] = Md5Checksum(filesZip)
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.0/changed.zip"] = changedZip,
|
||||
["https://s3.test/1.4.0/Files.zip"] = filesZip
|
||||
}));
|
||||
var downloader = CreateHttpDownloader(httpClient);
|
||||
|
||||
var package = await downloader.PrepareDeltaAsync(
|
||||
manifest,
|
||||
new("s3", "s3", "https://s3.test/1.4.0/PLONDS.json", 100),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlondsPackageMode.Delta, package.Mode);
|
||||
Assert.True(File.Exists(package.ManifestPath));
|
||||
Assert.True(File.Exists(package.ChangedZipPath));
|
||||
Assert.Equal("delta payload", File.ReadAllText(Path.Combine(package.ChangedDirectory!, "app.dll")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaChecksumFails_PreparesFullPackage()
|
||||
{
|
||||
var changedZip = CreateZip(("app.dll", "delta payload"));
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.1",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: "https://s3.test/1.4.1/changed.zip",
|
||||
filesUrl: "https://s3.test/1.4.1/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = "md5:00000000000000000000000000000000",
|
||||
["Files.zip"] = Md5Checksum(filesZip)
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.1/changed.zip"] = changedZip,
|
||||
["https://s3.test/1.4.1/Files.zip"] = filesZip
|
||||
}));
|
||||
var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient));
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.1/PLONDS.json", 100), manifest),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode);
|
||||
Assert.Equal("full payload", File.ReadAllText(Path.Combine(result.Package!.FilesDirectory!, "app.dll")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaUrlMissing_PreparesFullPackage()
|
||||
{
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.2",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: null,
|
||||
filesUrl: "https://s3.test/1.4.2/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["Files.zip"] = Md5Checksum(filesZip)
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.2/Files.zip"] = filesZip
|
||||
}));
|
||||
var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient));
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.2/PLONDS.json", 100), manifest),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenFullChecksumFails_ReturnsUiFailure()
|
||||
{
|
||||
var changedZip = CreateZip(("app.dll", "delta payload"));
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.3",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: "https://s3.test/1.4.3/changed.zip",
|
||||
filesUrl: "https://s3.test/1.4.3/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = "md5:00000000000000000000000000000000",
|
||||
["Files.zip"] = "md5:11111111111111111111111111111111"
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.3/changed.zip"] = changedZip,
|
||||
["https://s3.test/1.4.3/Files.zip"] = filesZip
|
||||
}));
|
||||
var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient));
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.3/PLONDS.json", 100), manifest),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.RequiresUiHandling);
|
||||
Assert.Contains("full package fallback also failed", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreparedPackageInstaller_AppliesDeltaPackageWithoutUpdateDownloadSystem()
|
||||
{
|
||||
var launcherRoot = Path.Combine(_tempRoot, "launcher");
|
||||
var currentDeployment = Path.Combine(launcherRoot, "app-1.0.0-0");
|
||||
Directory.CreateDirectory(currentDeployment);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, ".current"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "LanMountainDesktop.exe"), "exe");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "app.dll"), "old");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "keep.txt"), "keep");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "delete.txt"), "delete");
|
||||
|
||||
var changedDirectory = Path.Combine(_tempRoot, "changed");
|
||||
Directory.CreateDirectory(changedDirectory);
|
||||
File.WriteAllText(Path.Combine(changedDirectory, "app.dll"), "new");
|
||||
var manifestPath = Path.Combine(_tempRoot, "PLONDS.json");
|
||||
await File.WriteAllTextAsync(manifestPath, $$"""
|
||||
{
|
||||
"formatVersion": "2.0",
|
||||
"currentVersion": "1.1.0",
|
||||
"previousVersion": "1.0.0",
|
||||
"isFullUpdate": false,
|
||||
"requiresCleanInstall": false,
|
||||
"channel": "stable",
|
||||
"platform": "windows-x64",
|
||||
"updatedAt": "2026-06-01T00:00:00Z",
|
||||
"filesMap": {
|
||||
"LanMountainDesktop.exe": { "action": "reuse", "hash": "{{Sha256Text("exe")}}", "size": 3 },
|
||||
"app.dll": { "action": "replace", "hash": "{{Sha256Text("new")}}", "size": 3 },
|
||||
"keep.txt": { "action": "reuse", "hash": "{{Sha256Text("keep")}}", "size": 4 },
|
||||
"delete.txt": { "action": "delete", "hash": "", "size": 0 }
|
||||
},
|
||||
"changedFilesMap": {
|
||||
"app.dll": { "archivePath": "app.dll", "hash": "{{Sha256Text("new")}}", "size": 3 }
|
||||
},
|
||||
"checksums": {}
|
||||
}
|
||||
""");
|
||||
var package = new PlondsPreparedPackage(
|
||||
new Version(1, 1, 0),
|
||||
PlondsPackageMode.Delta,
|
||||
manifestPath,
|
||||
Path.Combine(_tempRoot, "changed.zip"),
|
||||
changedDirectory,
|
||||
null,
|
||||
null);
|
||||
|
||||
var result = await new PlondsPreparedPackageInstaller().InstallAsync(
|
||||
package,
|
||||
launcherRoot,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var target = Assert.Single(Directory.GetDirectories(launcherRoot, "app-1.1.0-*"));
|
||||
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "app.dll")));
|
||||
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
|
||||
Assert.False(File.Exists(Path.Combine(target, "delete.txt")));
|
||||
Assert.True(File.Exists(Path.Combine(target, ".current")));
|
||||
Assert.True(File.Exists(Path.Combine(currentDeployment, ".destroy")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreparedPackageInstaller_InstallsFullPackageFromCompleteRootLayout()
|
||||
{
|
||||
var launcherRoot = Path.Combine(_tempRoot, "launcher-full");
|
||||
var currentDeployment = Path.Combine(launcherRoot, "app-1.0.0-0");
|
||||
Directory.CreateDirectory(currentDeployment);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, ".current"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "LanMountainDesktop.exe"), "old-exe");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "app.dll"), "old");
|
||||
|
||||
var filesRoot = Path.Combine(_tempRoot, "Files-root");
|
||||
var fullAppDirectory = Path.Combine(filesRoot, "app-1.2.0");
|
||||
Directory.CreateDirectory(fullAppDirectory);
|
||||
File.WriteAllText(Path.Combine(filesRoot, "LanMountainDesktop.Launcher.exe"), "launcher");
|
||||
File.WriteAllText(Path.Combine(filesRoot, "LanMountainDesktop.AirAppRuntime.exe"), "runtime");
|
||||
File.WriteAllText(Path.Combine(fullAppDirectory, ".current"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(fullAppDirectory, "LanMountainDesktop.exe"), "new-exe");
|
||||
File.WriteAllText(Path.Combine(fullAppDirectory, "app.dll"), "new");
|
||||
|
||||
var package = new PlondsPreparedPackage(
|
||||
new Version(1, 2, 0),
|
||||
PlondsPackageMode.Full,
|
||||
Path.Combine(_tempRoot, "PLONDS.json"),
|
||||
null,
|
||||
null,
|
||||
Path.Combine(_tempRoot, "Files.zip"),
|
||||
filesRoot);
|
||||
|
||||
var result = await new PlondsPreparedPackageInstaller().InstallAsync(
|
||||
package,
|
||||
launcherRoot,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var target = Assert.Single(Directory.GetDirectories(launcherRoot, "app-1.2.0-*"));
|
||||
Assert.Equal("new-exe", File.ReadAllText(Path.Combine(target, "LanMountainDesktop.exe")));
|
||||
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "app.dll")));
|
||||
Assert.False(File.Exists(Path.Combine(target, "LanMountainDesktop.Launcher.exe")));
|
||||
Assert.True(File.Exists(Path.Combine(target, ".current")));
|
||||
Assert.True(File.Exists(Path.Combine(currentDeployment, ".destroy")));
|
||||
}
|
||||
|
||||
private static PlondsClientManifest CreateManifest(
|
||||
string version,
|
||||
IReadOnlyList<PlondsSourceDescriptor>? sources = null,
|
||||
PlondsClientDownloads? downloads = null,
|
||||
IReadOnlyDictionary<string, string>? checksums = null,
|
||||
bool requiresCleanInstall = false)
|
||||
{
|
||||
return new PlondsClientManifest(
|
||||
FormatVersion: "2.0",
|
||||
CurrentVersion: version,
|
||||
PreviousVersion: "1.0.0",
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
Channel: "stable",
|
||||
Platform: "windows-x64",
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),
|
||||
FilesMap: new Dictionary<string, PlondsClientFileEntry>(),
|
||||
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
|
||||
Checksums: checksums ?? new Dictionary<string, string>(),
|
||||
Downloads: downloads,
|
||||
Sources: sources ?? []);
|
||||
}
|
||||
|
||||
private PlondsHttpPackageDownloader CreateHttpDownloader(HttpClient httpClient)
|
||||
{
|
||||
return new PlondsHttpPackageDownloader(
|
||||
httpClient,
|
||||
new PlondsPackageStore(_tempRoot),
|
||||
new PlondsVerifier());
|
||||
}
|
||||
|
||||
private static PlondsClientDownloads CreateDownloads(string? changedUrl, string? filesUrl)
|
||||
{
|
||||
return new PlondsClientDownloads(
|
||||
GitHub: null,
|
||||
S3: new PlondsS3Downloads(
|
||||
Bucket: "bucket",
|
||||
Prefix: "lanmountain/update/plonds/1.4.0",
|
||||
ManifestKey: "lanmountain/update/plonds/1.4.0/PLONDS.json",
|
||||
ManifestUrl: "https://s3.test/1.4.0/PLONDS.json",
|
||||
ChangedZipKey: changedUrl is null ? null : "lanmountain/update/plonds/1.4.0/changed.zip",
|
||||
ChangedZipUrl: changedUrl,
|
||||
ChangedFolderKey: null,
|
||||
ChangedFolderUrl: null,
|
||||
FilesZipKey: filesUrl is null ? null : "lanmountain/update/plonds/1.4.0/Files.zip",
|
||||
FilesZipUrl: filesUrl,
|
||||
FilesFolderKey: null,
|
||||
FilesFolderUrl: null));
|
||||
}
|
||||
|
||||
private static byte[] CreateZip(params (string Path, string Contents)[] entries)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var (path, contents) in entries)
|
||||
{
|
||||
var entry = archive.CreateEntry(path);
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write(contents);
|
||||
}
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static string Md5Checksum(byte[] bytes)
|
||||
{
|
||||
return $"md5:{Convert.ToHexString(MD5.HashData(bytes)).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string Sha256Text(string text)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ManifestJson(string version, string extraFields = "")
|
||||
{
|
||||
var separator = string.IsNullOrWhiteSpace(extraFields) ? string.Empty : ",";
|
||||
return $$"""
|
||||
{
|
||||
"formatVersion": "2.0",
|
||||
"currentVersion": "{{version}}",
|
||||
"previousVersion": "1.0.0",
|
||||
"isFullUpdate": false,
|
||||
"requiresCleanInstall": false,
|
||||
"channel": "stable",
|
||||
"platform": "windows-x64",
|
||||
"updatedAt": "2026-06-01T00:00:00Z",
|
||||
"filesMap": {},
|
||||
"changedFilesMap": {},
|
||||
"checksums": {}{{separator}}
|
||||
{{extraFields}}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class FakeDownloader(bool deltaFails, bool fullFails) : IPlondsPackageDownloader
|
||||
{
|
||||
public int DeltaCalls { get; private set; }
|
||||
public int FullCalls { get; private set; }
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeltaCalls++;
|
||||
if (deltaFails)
|
||||
{
|
||||
throw new InvalidOperationException("delta failed");
|
||||
}
|
||||
|
||||
return Task.FromResult(CreatePackage(manifest, PlondsPackageMode.Delta));
|
||||
}
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FullCalls++;
|
||||
if (fullFails)
|
||||
{
|
||||
throw new InvalidOperationException("full failed");
|
||||
}
|
||||
|
||||
return Task.FromResult(CreatePackage(manifest, PlondsPackageMode.Full));
|
||||
}
|
||||
|
||||
private static PlondsPreparedPackage CreatePackage(PlondsClientManifest manifest, PlondsPackageMode mode)
|
||||
{
|
||||
PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version);
|
||||
return new PlondsPreparedPackage(
|
||||
version,
|
||||
mode,
|
||||
"PLONDS.json",
|
||||
mode is PlondsPackageMode.Delta ? "changed.zip" : null,
|
||||
mode is PlondsPackageMode.Delta ? "changed" : null,
|
||||
mode is PlondsPackageMode.Full ? "Files.zip" : null,
|
||||
mode is PlondsPackageMode.Full ? "Files" : null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SourceAwareFakeDownloader(string failingSourceId) : IPlondsPackageDownloader
|
||||
{
|
||||
public int DeltaCalls { get; private set; }
|
||||
public string? SuccessfulSourceId { get; private set; }
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeltaCalls++;
|
||||
if (string.Equals(source.Id, failingSourceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("source failed");
|
||||
}
|
||||
|
||||
SuccessfulSourceId = source.Id;
|
||||
return Task.FromResult(CreatePackage(manifest, source, PlondsPackageMode.Delta));
|
||||
}
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(source.Id, failingSourceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("source full failed");
|
||||
}
|
||||
|
||||
SuccessfulSourceId = source.Id;
|
||||
return Task.FromResult(CreatePackage(manifest, source, PlondsPackageMode.Full));
|
||||
}
|
||||
|
||||
private static PlondsPreparedPackage CreatePackage(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode)
|
||||
{
|
||||
PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version);
|
||||
return new PlondsPreparedPackage(
|
||||
version,
|
||||
mode,
|
||||
$"{source.Id}/PLONDS.json",
|
||||
mode is PlondsPackageMode.Delta ? $"{source.Id}/changed.zip" : null,
|
||||
mode is PlondsPackageMode.Delta ? $"{source.Id}/changed" : null,
|
||||
mode is PlondsPackageMode.Full ? $"{source.Id}/Files.zip" : null,
|
||||
mode is PlondsPackageMode.Full ? $"{source.Id}/Files" : null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManifestHandler(
|
||||
IReadOnlyDictionary<string, string> manifests,
|
||||
IReadOnlySet<string>? throwingUrls = null) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.ToString() ?? string.Empty;
|
||||
if (throwingUrls?.Contains(url) == true)
|
||||
{
|
||||
throw new HttpRequestException("manifest source failed");
|
||||
}
|
||||
|
||||
if (!manifests.TryGetValue(url, out var json))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AssetHandler(IReadOnlyDictionary<string, byte[]> assets) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.ToString() ?? string.Empty;
|
||||
if (!assets.TryGetValue(url, out var bytes))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(bytes)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Plonds;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
@@ -103,35 +104,111 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsUpdateManifestProvider_UsesSelectedUpdateSource()
|
||||
public async Task UpdateSettingsService_WhenPlondsSelected_UsesPlondsServiceWithoutCreatingOrchestrator()
|
||||
{
|
||||
var update = new FakeUpdateSettingsService
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
State = DefaultUpdateState() with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub }
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
|
||||
}
|
||||
};
|
||||
var plonds = new FakeManifestProvider("plonds");
|
||||
var github = new FakeManifestProvider("github");
|
||||
var provider = new SettingsUpdateManifestProvider(new FakeSettingsFacade(update), plonds, github);
|
||||
var plonds = new FakePlondsService
|
||||
{
|
||||
LatestResult = PlondsLatestResult.Available(
|
||||
new Version(1, 0, 0),
|
||||
new Version(9, 9, 9),
|
||||
[new PlondsManifestCandidate(
|
||||
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
CreatePlondsManifest("9.9.9"))])
|
||||
};
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS.");
|
||||
},
|
||||
plondsService: plonds);
|
||||
|
||||
var manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("github", manifest?.DistributionId);
|
||||
Assert.Equal(0, plonds.GetLatestCalls);
|
||||
Assert.Equal(1, github.GetLatestCalls);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
Assert.Equal("9.9.9", report.LatestVersion);
|
||||
Assert.Equal(1, plonds.FindLatestCalls);
|
||||
Assert.False(orchestratorCreated);
|
||||
}
|
||||
|
||||
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds };
|
||||
manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
[Fact]
|
||||
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
|
||||
{
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
|
||||
}
|
||||
};
|
||||
var plonds = new FakePlondsService
|
||||
{
|
||||
LatestResult = PlondsLatestResult.Available(
|
||||
new Version(1, 0, 0),
|
||||
new Version(9, 9, 9),
|
||||
[new PlondsManifestCandidate(
|
||||
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
CreatePlondsManifest("9.9.9", requiresCleanInstall: true))])
|
||||
};
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS check.");
|
||||
},
|
||||
plondsService: plonds);
|
||||
|
||||
Assert.Equal("plonds", manifest?.DistributionId);
|
||||
Assert.Equal(1, plonds.GetLatestCalls);
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
Assert.Equal(UpdatePayloadKind.FullInstaller, report.PayloadKind);
|
||||
Assert.Equal("9.9.9", report.LatestVersion);
|
||||
Assert.False(orchestratorCreated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator()
|
||||
{
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
}
|
||||
};
|
||||
var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
});
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
return orchestrator;
|
||||
},
|
||||
plondsService: new FakePlondsService());
|
||||
|
||||
var _ = service.CurrentPhase;
|
||||
|
||||
Assert.False(orchestratorCreated);
|
||||
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(orchestratorCreated);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -177,6 +254,33 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
LastUpdateCheckUtcMs: null,
|
||||
PendingUpdateSha256: null);
|
||||
|
||||
private static UpdateOrchestrator CreateTestOrchestrator(SettingsUpdateState state)
|
||||
{
|
||||
return new UpdateOrchestrator(
|
||||
new FakeManifestProvider("github"),
|
||||
new UpdateDownloadEngine(new FakeManifestProvider("github"), new ResumableDownloadService(new HttpClient(new EmptyHandler()))),
|
||||
new UpdateInstallGateway(),
|
||||
new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state })));
|
||||
}
|
||||
|
||||
private static PlondsClientManifest CreatePlondsManifest(string version, bool requiresCleanInstall = false)
|
||||
{
|
||||
return new PlondsClientManifest(
|
||||
FormatVersion: "2.0",
|
||||
CurrentVersion: version,
|
||||
PreviousVersion: "1.0.0",
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
Channel: "stable",
|
||||
Platform: "windows-x64",
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),
|
||||
FilesMap: new Dictionary<string, PlondsClientFileEntry>(),
|
||||
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
|
||||
Checksums: new Dictionary<string, string>(),
|
||||
Downloads: null,
|
||||
Sources: []);
|
||||
}
|
||||
|
||||
private sealed class FakeUpdateSettingsService : IUpdateSettingsService
|
||||
{
|
||||
public SettingsUpdateState State { get; set; } = DefaultUpdateState();
|
||||
@@ -263,9 +367,6 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
|
||||
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
public Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PlondsUpdatePayload?>(null);
|
||||
|
||||
public Task<LanMountainDesktop.Services.UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -285,6 +386,115 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
=> Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false));
|
||||
}
|
||||
|
||||
private sealed class FakePlondsService : IPlondsService
|
||||
{
|
||||
public PlondsLatestResult LatestResult { get; set; } = PlondsLatestResult.UpToDate(new Version(1, 0, 0), new Version(1, 0, 0));
|
||||
public PlondsPrepareResult PrepareResult { get; set; } = PlondsPrepareResult.FailedForUi("not prepared");
|
||||
public int FindLatestCalls { get; private set; }
|
||||
public int PrepareLatestCalls { get; private set; }
|
||||
|
||||
public Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
FindLatestCalls++;
|
||||
return Task.FromResult(LatestResult);
|
||||
}
|
||||
|
||||
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareLatestCalls++;
|
||||
return Task.FromResult(PrepareResult);
|
||||
}
|
||||
|
||||
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareLatestCalls++;
|
||||
return Task.FromResult(PrepareResult);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsService : ISettingsService
|
||||
{
|
||||
public event EventHandler<SettingsChangedEvent>? Changed;
|
||||
|
||||
public AppSettingsSnapshot Snapshot { get; init; } = new();
|
||||
|
||||
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
|
||||
{
|
||||
if (typeof(T) == typeof(AppSettingsSnapshot))
|
||||
{
|
||||
return (T)(object)Snapshot.Clone();
|
||||
}
|
||||
|
||||
return new T();
|
||||
}
|
||||
|
||||
public void SaveSnapshot<T>(
|
||||
SettingsScope scope,
|
||||
T snapshot,
|
||||
string? subjectId = null,
|
||||
string? placementId = null,
|
||||
string? sectionId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
if (snapshot is AppSettingsSnapshot appSettings)
|
||||
{
|
||||
CopyUpdateSettings(appSettings, Snapshot);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
|
||||
}
|
||||
|
||||
public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new()
|
||||
=> new();
|
||||
|
||||
public void SaveSection<T>(
|
||||
SettingsScope scope,
|
||||
string subjectId,
|
||||
string sectionId,
|
||||
T section,
|
||||
string? placementId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
|
||||
{
|
||||
}
|
||||
|
||||
public T? GetValue<T>(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null)
|
||||
=> default;
|
||||
|
||||
public void SetValue<T>(
|
||||
SettingsScope scope,
|
||||
string key,
|
||||
T value,
|
||||
string? subjectId = null,
|
||||
string? placementId = null,
|
||||
string? sectionId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
private static void CopyUpdateSettings(AppSettingsSnapshot source, AppSettingsSnapshot target)
|
||||
{
|
||||
target.IncludePrereleaseUpdates = source.IncludePrereleaseUpdates;
|
||||
target.UpdateChannel = source.UpdateChannel;
|
||||
target.UpdateMode = source.UpdateMode;
|
||||
target.UpdateDownloadSource = source.UpdateDownloadSource;
|
||||
target.UpdateDownloadThreads = source.UpdateDownloadThreads;
|
||||
target.ForceUpdateReinstall = source.ForceUpdateReinstall;
|
||||
target.UseGhProxyMirror = source.UseGhProxyMirror;
|
||||
target.PendingUpdateInstallerPath = source.PendingUpdateInstallerPath;
|
||||
target.PendingUpdateVersion = source.PendingUpdateVersion;
|
||||
target.PendingUpdatePublishedAtUtcMs = source.PendingUpdatePublishedAtUtcMs;
|
||||
target.LastUpdateCheckUtcMs = source.LastUpdateCheckUtcMs;
|
||||
target.PendingUpdateSha256 = source.PendingUpdateSha256;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider
|
||||
{
|
||||
public string ProviderName { get; } = providerName;
|
||||
@@ -318,6 +528,14 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
private sealed class EmptyHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService
|
||||
{
|
||||
public ISettingsService Settings => throw new NotSupportedException();
|
||||
|
||||
@@ -34,20 +34,7 @@ public sealed record UpdateCheckResult(
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage,
|
||||
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);
|
||||
bool ForceMode = false);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
@@ -162,10 +149,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
var preferredAsset = isUpdateAvailable
|
||||
? SelectPreferredInstallerAsset(release.Assets)
|
||||
: null;
|
||||
var plondsPayload = isUpdateAvailable
|
||||
? TryResolvePlondsPayload(release)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: isUpdateAvailable,
|
||||
@@ -173,8 +156,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
PlondsPayload: plondsPayload);
|
||||
ErrorMessage: null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -239,8 +221,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
@@ -249,8 +229,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: true,
|
||||
PlondsPayload: plondsPayload);
|
||||
ForceMode: true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -703,46 +682,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal interface IPlondsPackageDownloader
|
||||
{
|
||||
Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
10
LanMountainDesktop/Services/Plonds/IPlondsService.cs
Normal file
10
LanMountainDesktop/Services/Plonds/IPlondsService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal interface IPlondsService
|
||||
{
|
||||
Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken);
|
||||
|
||||
Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken);
|
||||
}
|
||||
25
LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs
Normal file
25
LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsClientDownloads(
|
||||
PlondsGitHubDownloads? GitHub,
|
||||
PlondsS3Downloads? S3);
|
||||
|
||||
internal sealed record PlondsGitHubDownloads(
|
||||
string? ReleaseUrl,
|
||||
string? ManifestUrl,
|
||||
string? ChangedZipUrl,
|
||||
string? FilesZipUrl);
|
||||
|
||||
internal sealed record PlondsS3Downloads(
|
||||
string? Bucket,
|
||||
string? Prefix,
|
||||
string? ManifestKey,
|
||||
string? ManifestUrl,
|
||||
string? ChangedZipKey,
|
||||
string? ChangedZipUrl,
|
||||
string? ChangedFolderKey,
|
||||
string? ChangedFolderUrl,
|
||||
string? FilesZipKey,
|
||||
string? FilesZipUrl,
|
||||
string? FilesFolderKey,
|
||||
string? FilesFolderUrl);
|
||||
28
LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs
Normal file
28
LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsClientManifest(
|
||||
string FormatVersion,
|
||||
string CurrentVersion,
|
||||
string PreviousVersion,
|
||||
bool IsFullUpdate,
|
||||
bool RequiresCleanInstall,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyDictionary<string, PlondsClientFileEntry> FilesMap,
|
||||
IReadOnlyDictionary<string, PlondsClientChangedFileEntry> ChangedFilesMap,
|
||||
IReadOnlyDictionary<string, string> Checksums,
|
||||
PlondsClientDownloads? Downloads,
|
||||
IReadOnlyList<PlondsSourceDescriptor>? Sources);
|
||||
|
||||
internal sealed record PlondsClientFileEntry(
|
||||
string Action,
|
||||
string Hash,
|
||||
long Size,
|
||||
string HashAlgorithm = "sha256");
|
||||
|
||||
internal sealed record PlondsClientChangedFileEntry(
|
||||
string ArchivePath,
|
||||
string Hash,
|
||||
long Size,
|
||||
string HashAlgorithm = "sha256");
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal static class PlondsClientServiceFactory
|
||||
{
|
||||
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
|
||||
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
|
||||
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json";
|
||||
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
|
||||
|
||||
public static IPlondsService CreateDefault(HttpClient? httpClient = null)
|
||||
{
|
||||
var client = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
var dataRoot = Path.Combine(AppDataPathProvider.GetDataRoot(), "PLONDS");
|
||||
var sourceStore = new PlondsSourceStore(Path.Combine(dataRoot, "sources.json"));
|
||||
var registry = new PlondsSourceRegistry(CreateBuiltInSources());
|
||||
foreach (var source in sourceStore.LoadAsync(CancellationToken.None).GetAwaiter().GetResult())
|
||||
{
|
||||
registry.Add(source);
|
||||
}
|
||||
|
||||
var packageStore = new PlondsPackageStore(Path.Combine(dataRoot, "packages"));
|
||||
return new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(client),
|
||||
new PlondsDownloadPlanner(new PlondsHttpPackageDownloader(client, packageStore, new PlondsVerifier())),
|
||||
sourceStore);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<PlondsSourceDescriptor> CreateBuiltInSources()
|
||||
{
|
||||
return
|
||||
[
|
||||
new(
|
||||
Id: "s3",
|
||||
Kind: "s3",
|
||||
ManifestUrl: ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl),
|
||||
Priority: 100),
|
||||
new(
|
||||
Id: "github",
|
||||
Kind: "github",
|
||||
ManifestUrl: ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl),
|
||||
Priority: 50)
|
||||
];
|
||||
}
|
||||
|
||||
private static string ResolveManifestUrl(string environmentVariable, string fallback)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(environmentVariable);
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
}
|
||||
50
LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs
Normal file
50
LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsDownloadPlanner(IPlondsPackageDownloader downloader)
|
||||
{
|
||||
public async Task<PlondsPrepareResult> PrepareAsync(
|
||||
PlondsManifestCandidate candidate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidate);
|
||||
|
||||
if (candidate.Manifest.RequiresCleanInstall)
|
||||
{
|
||||
return PlondsPrepareResult.FailedForUi(
|
||||
"PLONDS manifest requires a clean install. Use the Host Update installer flow instead.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var deltaPackage = await downloader
|
||||
.PrepareDeltaAsync(candidate.Manifest, candidate.Source, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return PlondsPrepareResult.Prepared(deltaPackage);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception deltaError)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPackage = await downloader
|
||||
.PrepareFullAsync(candidate.Manifest, candidate.Source, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return PlondsPrepareResult.Prepared(fullPackage);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception fullError)
|
||||
{
|
||||
return PlondsPrepareResult.FailedForUi(
|
||||
$"PLONDS delta package failed and full package fallback also failed. Delta: {deltaError.Message}; Full: {fullError.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal static class PlondsDownloadUrlResolver
|
||||
{
|
||||
public static IReadOnlyList<Uri> Resolve(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode)
|
||||
{
|
||||
var urls = new List<string?>();
|
||||
var sourceKind = source.Kind.Trim().ToLowerInvariant();
|
||||
|
||||
if (sourceKind is "s3")
|
||||
{
|
||||
AddS3(urls, manifest, mode);
|
||||
}
|
||||
else if (sourceKind is "github")
|
||||
{
|
||||
AddGitHub(urls, manifest, mode);
|
||||
}
|
||||
|
||||
urls.Add(DerivePackageUrl(source.ManifestUrl, mode));
|
||||
AddS3(urls, manifest, mode);
|
||||
AddGitHub(urls, manifest, mode);
|
||||
|
||||
return urls
|
||||
.Where(url => !string.IsNullOrWhiteSpace(url))
|
||||
.Select(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.OfType<Uri>()
|
||||
.Where(uri => uri.Scheme is "http" or "https")
|
||||
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static void AddS3(List<string?> urls, PlondsClientManifest manifest, PlondsPackageMode mode)
|
||||
{
|
||||
urls.Add(mode is PlondsPackageMode.Delta
|
||||
? manifest.Downloads?.S3?.ChangedZipUrl
|
||||
: manifest.Downloads?.S3?.FilesZipUrl);
|
||||
}
|
||||
|
||||
private static void AddGitHub(List<string?> urls, PlondsClientManifest manifest, PlondsPackageMode mode)
|
||||
{
|
||||
urls.Add(mode is PlondsPackageMode.Delta
|
||||
? manifest.Downloads?.GitHub?.ChangedZipUrl
|
||||
: manifest.Downloads?.GitHub?.FilesZipUrl);
|
||||
}
|
||||
|
||||
private static string? DerivePackageUrl(string manifestUrl, PlondsPackageMode mode)
|
||||
{
|
||||
if (!Uri.TryCreate(manifestUrl, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var packageName = mode is PlondsPackageMode.Delta ? "changed.zip" : "Files.zip";
|
||||
var builder = new UriBuilder(uri);
|
||||
var lastSlash = builder.Path.LastIndexOf('/');
|
||||
builder.Path = lastSlash >= 0
|
||||
? $"{builder.Path[..(lastSlash + 1)]}{packageName}"
|
||||
: packageName;
|
||||
builder.Query = string.Empty;
|
||||
builder.Fragment = string.Empty;
|
||||
return builder.Uri.AbsoluteUri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsHttpPackageDownloader(
|
||||
HttpClient httpClient,
|
||||
PlondsPackageStore packageStore,
|
||||
PlondsVerifier verifier) : IPlondsPackageDownloader
|
||||
{
|
||||
public Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (manifest.IsFullUpdate || manifest.RequiresCleanInstall)
|
||||
{
|
||||
throw new InvalidOperationException("PLONDS manifest requires a full package.");
|
||||
}
|
||||
|
||||
return PrepareAsync(manifest, source, PlondsPackageMode.Delta, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return PrepareAsync(manifest, source, PlondsPackageMode.Full, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PlondsPreparedPackage> PrepareAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var urls = PlondsDownloadUrlResolver.Resolve(manifest, source, mode);
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"PLONDS manifest does not provide a {mode} package URL.");
|
||||
}
|
||||
|
||||
Exception? lastError = null;
|
||||
foreach (var url in urls)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var staging = await packageStore.CreateStagingAsync(manifest, source, mode, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await DownloadToFileAsync(url, staging.PackageZipPath, cancellationToken).ConfigureAwait(false);
|
||||
await verifier.VerifyFileAsync(
|
||||
staging.PackageZipPath,
|
||||
manifest.Checksums,
|
||||
GetChecksumKeys(mode, url),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
packageStore.ExtractPackage(staging.PackageZipPath, staging.ExtractDirectory);
|
||||
return staging.ToPreparedPackage();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Failed to prepare PLONDS {mode} package.", lastError);
|
||||
}
|
||||
|
||||
private async Task DownloadToFileAsync(Uri url, string destinationPath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"PLONDS package download failed: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(destinationPath));
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var partialPath = $"{destinationPath}.partial";
|
||||
try
|
||||
{
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var target = File.Create(partialPath))
|
||||
{
|
||||
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
File.Move(partialPath, destinationPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(partialPath))
|
||||
{
|
||||
File.Delete(partialPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetChecksumKeys(PlondsPackageMode mode, Uri url)
|
||||
{
|
||||
var urlFileName = Path.GetFileName(url.LocalPath);
|
||||
var keys = mode is PlondsPackageMode.Delta
|
||||
? new[] { "changed.zip", urlFileName }
|
||||
: new[] { "Files.zip", "files.zip", "files-windows-x64.zip", urlFileName };
|
||||
|
||||
return keys
|
||||
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsInstallResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
string? ErrorCode = null);
|
||||
28
LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs
Normal file
28
LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsLatestResult(
|
||||
bool Success,
|
||||
bool IsUpdateAvailable,
|
||||
Version CurrentVersion,
|
||||
Version? LatestVersion,
|
||||
IReadOnlyList<PlondsManifestCandidate> Candidates,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public static PlondsLatestResult Available(
|
||||
Version currentVersion,
|
||||
Version latestVersion,
|
||||
IReadOnlyList<PlondsManifestCandidate> candidates)
|
||||
{
|
||||
return new PlondsLatestResult(true, true, currentVersion, latestVersion, candidates, null);
|
||||
}
|
||||
|
||||
public static PlondsLatestResult UpToDate(Version currentVersion, Version latestVersion)
|
||||
{
|
||||
return new PlondsLatestResult(true, false, currentVersion, latestVersion, [], null);
|
||||
}
|
||||
|
||||
public static PlondsLatestResult Failed(Version currentVersion, string message)
|
||||
{
|
||||
return new PlondsLatestResult(false, false, currentVersion, null, [], message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsManifestCandidate(
|
||||
PlondsSourceDescriptor Source,
|
||||
PlondsClientManifest Manifest);
|
||||
27
LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs
Normal file
27
LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsManifestClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<PlondsClientManifest?> GetManifestAsync(PlondsSourceDescriptor source, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<PlondsClientManifest>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
}
|
||||
53
LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs
Normal file
53
LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal static class PlondsManifestSelector
|
||||
{
|
||||
public static PlondsManifestCandidate? SelectHighestVersion(IEnumerable<PlondsManifestCandidate> candidates)
|
||||
{
|
||||
return SelectHighestVersionCandidates(candidates).FirstOrDefault();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<PlondsManifestCandidate> SelectHighestVersionCandidates(IEnumerable<PlondsManifestCandidate> candidates)
|
||||
{
|
||||
var usableCandidates = candidates
|
||||
.Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _))
|
||||
.OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion))
|
||||
.ThenByDescending(candidate => candidate.Source.Priority)
|
||||
.ToArray();
|
||||
|
||||
var highest = usableCandidates.FirstOrDefault();
|
||||
if (highest is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var highestVersion = ParseVersion(highest.Manifest.CurrentVersion);
|
||||
return usableCandidates
|
||||
.Where(candidate => ParseVersion(candidate.Manifest.CurrentVersion).CompareTo(highestVersion) == 0)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static bool TryParseVersion(string? value, out Version version)
|
||||
{
|
||||
version = new Version(0, 0, 0);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = parsed.Revision >= 0
|
||||
? new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build), parsed.Revision)
|
||||
: new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string value)
|
||||
{
|
||||
return TryParseVersion(value, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
}
|
||||
7
LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs
Normal file
7
LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal enum PlondsPackageMode
|
||||
{
|
||||
Delta,
|
||||
Full
|
||||
}
|
||||
155
LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs
Normal file
155
LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsPackageStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly string _rootDirectory;
|
||||
|
||||
public PlondsPackageStore(string rootDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory))
|
||||
{
|
||||
throw new ArgumentException("PLONDS package store root is required.", nameof(rootDirectory));
|
||||
}
|
||||
|
||||
_rootDirectory = Path.GetFullPath(rootDirectory);
|
||||
Directory.CreateDirectory(_rootDirectory);
|
||||
}
|
||||
|
||||
public async Task<PlondsPackageStaging> CreateStagingAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version))
|
||||
{
|
||||
throw new InvalidDataException($"Invalid PLONDS version: {manifest.CurrentVersion}");
|
||||
}
|
||||
|
||||
var modeDirectoryName = mode is PlondsPackageMode.Delta ? "delta" : "full";
|
||||
var stagingRoot = Path.Combine(
|
||||
_rootDirectory,
|
||||
SanitizePathSegment(version.ToString()),
|
||||
SanitizePathSegment(source.Id),
|
||||
modeDirectoryName);
|
||||
|
||||
EnsureCleanDirectory(stagingRoot);
|
||||
|
||||
var manifestPath = Path.Combine(stagingRoot, "PLONDS.json");
|
||||
await using (var manifestStream = File.Create(manifestPath))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(manifestStream, manifest, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var zipPath = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed.zip" : "Files.zip");
|
||||
var extractDirectory = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed" : "Files");
|
||||
Directory.CreateDirectory(extractDirectory);
|
||||
|
||||
return new PlondsPackageStaging(version, mode, stagingRoot, manifestPath, zipPath, extractDirectory);
|
||||
}
|
||||
|
||||
public void ExtractPackage(string zipPath, string destinationDirectory)
|
||||
{
|
||||
var resolvedDestination = Path.GetFullPath(destinationDirectory);
|
||||
EnsureStorePath(resolvedDestination);
|
||||
EnsureCleanDirectory(resolvedDestination);
|
||||
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var destinationPath = Path.GetFullPath(Path.Combine(resolvedDestination, entry.FullName));
|
||||
EnsureChildPath(resolvedDestination, destinationPath);
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
Directory.CreateDirectory(destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
entry.ExtractToFile(destinationPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureCleanDirectory(string path)
|
||||
{
|
||||
var resolvedPath = Path.GetFullPath(path);
|
||||
EnsureStorePath(resolvedPath);
|
||||
if (Directory.Exists(resolvedPath))
|
||||
{
|
||||
Directory.Delete(resolvedPath, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(resolvedPath);
|
||||
}
|
||||
|
||||
private void EnsureStorePath(string path)
|
||||
{
|
||||
if (!IsSameOrChildPath(_rootDirectory, path))
|
||||
{
|
||||
throw new InvalidOperationException($"PLONDS staging path is outside the package store: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureChildPath(string parent, string child)
|
||||
{
|
||||
if (!IsSameOrChildPath(parent, child))
|
||||
{
|
||||
throw new InvalidDataException($"PLONDS package entry escapes the staging directory: {child}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameOrChildPath(string parent, string child)
|
||||
{
|
||||
var resolvedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var resolvedChild = Path.GetFullPath(child);
|
||||
return string.Equals(resolvedParent, resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)
|
||||
|| resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
|| resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string SanitizePathSegment(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
|
||||
var sanitized = new string(chars).Trim();
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PlondsPackageStaging(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string RootDirectory,
|
||||
string ManifestPath,
|
||||
string PackageZipPath,
|
||||
string ExtractDirectory)
|
||||
{
|
||||
public PlondsPreparedPackage ToPreparedPackage()
|
||||
{
|
||||
return new PlondsPreparedPackage(
|
||||
Version,
|
||||
Mode,
|
||||
ManifestPath,
|
||||
Mode is PlondsPackageMode.Delta ? PackageZipPath : null,
|
||||
Mode is PlondsPackageMode.Delta ? ExtractDirectory : null,
|
||||
Mode is PlondsPackageMode.Full ? PackageZipPath : null,
|
||||
Mode is PlondsPackageMode.Full ? ExtractDirectory : null);
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs
Normal file
12
LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsPrepareResult(
|
||||
bool Success,
|
||||
PlondsPreparedPackage? Package,
|
||||
string? ErrorMessage,
|
||||
bool RequiresUiHandling)
|
||||
{
|
||||
public static PlondsPrepareResult Prepared(PlondsPreparedPackage package) => new(true, package, null, false);
|
||||
|
||||
public static PlondsPrepareResult FailedForUi(string message) => new(false, null, message, true);
|
||||
}
|
||||
10
LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs
Normal file
10
LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsPreparedPackage(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string ManifestPath,
|
||||
string? ChangedZipPath,
|
||||
string? ChangedDirectory,
|
||||
string? FilesZipPath,
|
||||
string? FilesDirectory);
|
||||
@@ -0,0 +1,417 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsPreparedPackageInstaller
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public async Task<PlondsInstallResult> InstallAsync(
|
||||
PlondsPreparedPackage package,
|
||||
string launcherRoot,
|
||||
IProgress<InstallProgressReport>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(package);
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (package.Mode is PlondsPackageMode.Full)
|
||||
{
|
||||
return InstallFullPackage(package, launcherRoot, progress, cancellationToken);
|
||||
}
|
||||
|
||||
var manifest = await LoadManifestAsync(package.ManifestPath, cancellationToken).ConfigureAwait(false);
|
||||
return InstallDeltaPackage(package, manifest, launcherRoot, progress, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PLONDS.Install", $"Prepared PLONDS package install failed: {ex.Message}");
|
||||
return new PlondsInstallResult(false, ex.Message, "plonds_install_failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static PlondsInstallResult InstallFullPackage(
|
||||
PlondsPreparedPackage package,
|
||||
string launcherRoot,
|
||||
IProgress<InstallProgressReport>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(package.FilesDirectory) || !Directory.Exists(package.FilesDirectory))
|
||||
{
|
||||
return new PlondsInstallResult(false, "PLONDS full package directory is missing.", "staging_incomplete");
|
||||
}
|
||||
|
||||
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.FilesDirectory, package.Version);
|
||||
var currentDeployment = FindCurrentDeploymentDirectory(launcherRoot);
|
||||
var targetDeployment = BuildNextDeploymentDirectory(launcherRoot, package.Version.ToString());
|
||||
|
||||
progress?.Report(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 15, null, 0, 0));
|
||||
PrepareTargetDirectory(targetDeployment);
|
||||
CopyDirectory(sourceAppDirectory, targetDeployment, cancellationToken, skipMarkers: true);
|
||||
|
||||
progress?.Report(new InstallProgressReport(InstallStage.ActivateDeployment, "Activating deployment...", 85, null, 0, 0));
|
||||
ActivateDeployment(currentDeployment, targetDeployment);
|
||||
progress?.Report(new InstallProgressReport(InstallStage.Completed, $"Updated to {package.Version}.", 100, null, 0, 0));
|
||||
return new PlondsInstallResult(true, null);
|
||||
}
|
||||
|
||||
private static PlondsInstallResult InstallDeltaPackage(
|
||||
PlondsPreparedPackage package,
|
||||
PlondsClientManifest manifest,
|
||||
string launcherRoot,
|
||||
IProgress<InstallProgressReport>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(package.ChangedDirectory) || !Directory.Exists(package.ChangedDirectory))
|
||||
{
|
||||
return new PlondsInstallResult(false, "PLONDS changed package directory is missing.", "staging_incomplete");
|
||||
}
|
||||
|
||||
var currentDeployment = FindCurrentDeploymentDirectory(launcherRoot);
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
return new PlondsInstallResult(false, "No current deployment was found for PLONDS delta install.", "current_missing");
|
||||
}
|
||||
|
||||
var targetDeployment = BuildNextDeploymentDirectory(launcherRoot, package.Version.ToString());
|
||||
var fileEntries = manifest.FilesMap ?? new Dictionary<string, PlondsClientFileEntry>();
|
||||
|
||||
progress?.Report(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 15, null, 0, fileEntries.Count));
|
||||
PrepareTargetDirectory(targetDeployment);
|
||||
CopyDirectory(currentDeployment, targetDeployment, cancellationToken, skipMarkers: true);
|
||||
|
||||
var applied = 0;
|
||||
foreach (var (relativePath, entry) in fileEntries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ApplyDeltaEntry(relativePath, entry, manifest, package.ChangedDirectory, targetDeployment);
|
||||
applied++;
|
||||
progress?.Report(new InstallProgressReport(
|
||||
InstallStage.ApplyFiles,
|
||||
"Applying PLONDS files...",
|
||||
20 + (applied * 45 / Math.Max(1, fileEntries.Count)),
|
||||
relativePath,
|
||||
applied,
|
||||
fileEntries.Count));
|
||||
}
|
||||
|
||||
VerifyFiles(fileEntries, targetDeployment, progress, cancellationToken);
|
||||
|
||||
progress?.Report(new InstallProgressReport(InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count));
|
||||
ActivateDeployment(currentDeployment, targetDeployment);
|
||||
progress?.Report(new InstallProgressReport(InstallStage.Completed, $"Updated to {package.Version}.", 100, null, fileEntries.Count, fileEntries.Count));
|
||||
return new PlondsInstallResult(true, null);
|
||||
}
|
||||
|
||||
private static async Task<PlondsClientManifest> LoadManifestAsync(string manifestPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException("PLONDS manifest is missing.", manifestPath);
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(manifestPath);
|
||||
return await JsonSerializer.DeserializeAsync<PlondsClientManifest>(stream, JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("PLONDS manifest is empty or invalid.");
|
||||
}
|
||||
|
||||
private static void ApplyDeltaEntry(
|
||||
string relativePath,
|
||||
PlondsClientFileEntry entry,
|
||||
PlondsClientManifest manifest,
|
||||
string changedDirectory,
|
||||
string targetDeployment)
|
||||
{
|
||||
var normalizedPath = NormalizeRelativePath(relativePath);
|
||||
var targetPath = Path.GetFullPath(Path.Combine(targetDeployment, normalizedPath));
|
||||
EnsureChildPath(targetDeployment, targetPath);
|
||||
|
||||
var action = string.IsNullOrWhiteSpace(entry.Action) ? "replace" : entry.Action.Trim();
|
||||
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var archivePath = manifest.ChangedFilesMap is not null &&
|
||||
manifest.ChangedFilesMap.TryGetValue(relativePath, out var changedEntry) &&
|
||||
!string.IsNullOrWhiteSpace(changedEntry.ArchivePath)
|
||||
? changedEntry.ArchivePath
|
||||
: normalizedPath;
|
||||
|
||||
var sourcePath = Path.GetFullPath(Path.Combine(changedDirectory, NormalizeRelativePath(archivePath)));
|
||||
EnsureChildPath(changedDirectory, sourcePath);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"PLONDS changed file is missing: {archivePath}", sourcePath);
|
||||
}
|
||||
|
||||
var targetDirectory = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
private static void VerifyFiles(
|
||||
IReadOnlyDictionary<string, PlondsClientFileEntry> fileEntries,
|
||||
string targetDeployment,
|
||||
IProgress<InstallProgressReport>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var verified = 0;
|
||||
foreach (var (relativePath, entry) in fileEntries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.Equals(entry.Action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
verified++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.Hash))
|
||||
{
|
||||
verified++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(Path.Combine(targetDeployment, NormalizeRelativePath(relativePath)));
|
||||
EnsureChildPath(targetDeployment, targetPath);
|
||||
if (!File.Exists(targetPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Expected PLONDS target file was not created: {relativePath}", targetPath);
|
||||
}
|
||||
|
||||
var actual = ComputeHash(targetPath, entry.HashAlgorithm);
|
||||
if (!string.Equals(actual, entry.Hash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException($"PLONDS target hash mismatch for {relativePath}. Expected {entry.Hash}, actual {actual}.");
|
||||
}
|
||||
|
||||
verified++;
|
||||
progress?.Report(new InstallProgressReport(
|
||||
InstallStage.VerifyHashes,
|
||||
"Verifying PLONDS files...",
|
||||
65 + (verified * 15 / Math.Max(1, fileEntries.Count)),
|
||||
relativePath,
|
||||
verified,
|
||||
fileEntries.Count));
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrepareTargetDirectory(string targetDeployment)
|
||||
{
|
||||
if (Directory.Exists(targetDeployment))
|
||||
{
|
||||
Directory.Delete(targetDeployment, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
}
|
||||
|
||||
private static void CopyDirectory(
|
||||
string sourceDirectory,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken,
|
||||
bool skipMarkers = false)
|
||||
{
|
||||
var resolvedSource = Path.GetFullPath(sourceDirectory);
|
||||
foreach (var sourcePath in Directory.EnumerateFiles(resolvedSource, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedSource, sourcePath));
|
||||
if (skipMarkers && IsDeploymentMarker(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(Path.Combine(targetDirectory, relativePath));
|
||||
EnsureChildPath(targetDirectory, targetPath);
|
||||
var targetParent = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetParent))
|
||||
{
|
||||
Directory.CreateDirectory(targetParent);
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindCurrentDeploymentDirectory(string launcherRoot)
|
||||
{
|
||||
if (!Directory.Exists(launcherRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
return Directory.GetDirectories(launcherRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||
.Where(path => File.Exists(Path.Combine(path, executable)) || File.Exists(Path.Combine(path, ".current")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path),
|
||||
HasCurrent = File.Exists(Path.Combine(path, ".current"))
|
||||
})
|
||||
.OrderBy(x => x.HasCurrent ? 0 : 1)
|
||||
.ThenByDescending(x => x.Version)
|
||||
.Select(x => x.Path)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string ResolveFullPackageAppDirectory(string filesDirectory, Version version)
|
||||
{
|
||||
var resolvedRoot = Path.GetFullPath(filesDirectory);
|
||||
if (File.Exists(Path.Combine(resolvedRoot, "LanMountainDesktop.exe")))
|
||||
{
|
||||
return resolvedRoot;
|
||||
}
|
||||
|
||||
var exactAppDirectory = Path.Combine(resolvedRoot, $"app-{version}");
|
||||
if (Directory.Exists(exactAppDirectory) &&
|
||||
File.Exists(Path.Combine(exactAppDirectory, "LanMountainDesktop.exe")))
|
||||
{
|
||||
return exactAppDirectory;
|
||||
}
|
||||
|
||||
var appDirectory = Directory.GetDirectories(resolvedRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => File.Exists(Path.Combine(path, "LanMountainDesktop.exe")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.Select(item => item.Path)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(appDirectory))
|
||||
{
|
||||
return appDirectory;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("PLONDS full package does not contain an app deployment directory.");
|
||||
}
|
||||
|
||||
private static string BuildNextDeploymentDirectory(string launcherRoot, string targetVersion)
|
||||
{
|
||||
Directory.CreateDirectory(launcherRoot);
|
||||
var sanitized = SanitizePathSegment(targetVersion);
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
var candidate = Path.Combine(launcherRoot, $"app-{sanitized}-{index}");
|
||||
if (!Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ActivateDeployment(string? currentDeployment, string targetDeployment)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
|
||||
TryDeleteFile(Path.Combine(targetDeployment, ".partial"));
|
||||
TryDeleteFile(Path.Combine(targetDeployment, ".destroy"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(currentDeployment) && Directory.Exists(currentDeployment))
|
||||
{
|
||||
TryDeleteFile(Path.Combine(currentDeployment, ".current"));
|
||||
File.WriteAllText(Path.Combine(currentDeployment, ".destroy"), string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string filePath, string algorithm)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var normalized = string.IsNullOrWhiteSpace(algorithm) ? "sha256" : algorithm.Trim().ToLowerInvariant();
|
||||
var hash = normalized == "md5"
|
||||
? MD5.HashData(stream)
|
||||
: SHA256.HashData(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Version ParseVersionFromDirectory(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
var segments = fileName.Split('-');
|
||||
return segments.Length >= 2 && Version.TryParse(segments[1], out var version)
|
||||
? version
|
||||
: new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static void EnsureChildPath(string parent, string child)
|
||||
{
|
||||
var resolvedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var resolvedChild = Path.GetFullPath(child);
|
||||
if (!resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
|
||||
!resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(resolvedParent, resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException($"PLONDS path escapes its root: {child}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string value)
|
||||
{
|
||||
return value.Replace('\\', '/').TrimStart('/');
|
||||
}
|
||||
|
||||
private static bool IsDeploymentMarker(string relativePath)
|
||||
{
|
||||
return relativePath is ".current" or ".partial" or ".destroy";
|
||||
}
|
||||
|
||||
private static string SanitizePathSegment(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var sanitized = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim();
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "0.0.0" : sanitized;
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
107
LanMountainDesktop/Services/Plonds/PlondsService.cs
Normal file
107
LanMountainDesktop/Services/Plonds/PlondsService.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsService(
|
||||
PlondsSourceRegistry sourceRegistry,
|
||||
PlondsManifestClient manifestClient,
|
||||
PlondsDownloadPlanner downloadPlanner,
|
||||
PlondsSourceStore? sourceStore = null) : IPlondsService
|
||||
{
|
||||
public async Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(currentVersion);
|
||||
|
||||
var selectedCandidates = await DiscoverHighestVersionCandidatesAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (selectedCandidates.Count == 0)
|
||||
{
|
||||
return PlondsLatestResult.Failed(currentVersion, "No usable PLONDS manifest was found.");
|
||||
}
|
||||
|
||||
var selected = selectedCandidates[0];
|
||||
if (!PlondsManifestSelector.TryParseVersion(selected.Manifest.CurrentVersion, out var latestVersion))
|
||||
{
|
||||
return PlondsLatestResult.Failed(currentVersion, $"Invalid PLONDS version: {selected.Manifest.CurrentVersion}");
|
||||
}
|
||||
|
||||
return latestVersion.CompareTo(currentVersion) > 0
|
||||
? PlondsLatestResult.Available(currentVersion, latestVersion, selectedCandidates)
|
||||
: PlondsLatestResult.UpToDate(currentVersion, latestVersion);
|
||||
}
|
||||
|
||||
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return FindAndPrepareLatestAsync(new Version(0, 0, 0), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
var latest = await FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
if (!latest.Success)
|
||||
{
|
||||
return PlondsPrepareResult.FailedForUi(latest.ErrorMessage ?? "No usable PLONDS manifest was found.");
|
||||
}
|
||||
|
||||
if (!latest.IsUpdateAvailable)
|
||||
{
|
||||
return PlondsPrepareResult.FailedForUi("No newer PLONDS version was found.");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
foreach (var selected in latest.Candidates)
|
||||
{
|
||||
var result = await downloadPlanner.PrepareAsync(selected, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
|
||||
{
|
||||
errors.Add($"{selected.Source.Id}: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
return PlondsPrepareResult.FailedForUi(string.Join(Environment.NewLine, errors));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<PlondsManifestCandidate>> DiscoverHighestVersionCandidatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = new List<PlondsManifestCandidate>();
|
||||
var sources = sourceRegistry.Sources.ToArray();
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PlondsClientManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = await manifestClient.GetManifestAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PLONDS.Source", $"Failed to read PLONDS manifest from source '{source.Id}'.", ex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifestSources = manifest.Sources ?? [];
|
||||
sourceRegistry.AddRange(manifestSources);
|
||||
if (manifestSources.Count > 0 && sourceStore is not null)
|
||||
{
|
||||
await sourceStore.SaveAsync(sourceRegistry.Sources, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
candidates.Add(new PlondsManifestCandidate(source, manifest));
|
||||
}
|
||||
|
||||
return PlondsManifestSelector.SelectHighestVersionCandidates(candidates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsSourceDescriptor(
|
||||
string Id,
|
||||
string Kind,
|
||||
string ManifestUrl,
|
||||
int Priority = 0);
|
||||
57
LanMountainDesktop/Services/Plonds/PlondsSourceRegistry.cs
Normal file
57
LanMountainDesktop/Services/Plonds/PlondsSourceRegistry.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsSourceRegistry
|
||||
{
|
||||
private readonly List<PlondsSourceDescriptor> _sources = [];
|
||||
|
||||
public PlondsSourceRegistry(IEnumerable<PlondsSourceDescriptor> initialSources)
|
||||
{
|
||||
AddRange(initialSources);
|
||||
}
|
||||
|
||||
public IReadOnlyList<PlondsSourceDescriptor> Sources => _sources;
|
||||
|
||||
public void AddRange(IEnumerable<PlondsSourceDescriptor>? sources)
|
||||
{
|
||||
if (sources is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
Add(source);
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(PlondsSourceDescriptor source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source.Id) || string.IsNullOrWhiteSpace(source.ManifestUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = source with
|
||||
{
|
||||
Id = source.Id.Trim(),
|
||||
Kind = string.IsNullOrWhiteSpace(source.Kind) ? "http" : source.Kind.Trim(),
|
||||
ManifestUrl = source.ManifestUrl.Trim()
|
||||
};
|
||||
|
||||
var existingIndex = _sources.FindIndex(item =>
|
||||
string.Equals(item.Id, normalized.Id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
_sources[existingIndex] = normalized;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sources.Any(item => string.Equals(item.ManifestUrl, normalized.ManifestUrl, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sources.Add(normalized);
|
||||
}
|
||||
}
|
||||
57
LanMountainDesktop/Services/Plonds/PlondsSourceStore.cs
Normal file
57
LanMountainDesktop/Services/Plonds/PlondsSourceStore.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsSourceStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly string _sourceFilePath;
|
||||
|
||||
public PlondsSourceStore(string sourceFilePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceFilePath))
|
||||
{
|
||||
throw new ArgumentException("PLONDS source cache path is required.", nameof(sourceFilePath));
|
||||
}
|
||||
|
||||
_sourceFilePath = Path.GetFullPath(sourceFilePath);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlondsSourceDescriptor>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(_sourceFilePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(_sourceFilePath);
|
||||
var document = await JsonSerializer.DeserializeAsync<PlondsSourceStoreDocument>(stream, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return document?.Sources ?? [];
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IEnumerable<PlondsSourceDescriptor> sources, CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_sourceFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var normalized = new PlondsSourceRegistry(sources).Sources.ToArray();
|
||||
var document = new PlondsSourceStoreDocument(normalized);
|
||||
await using var stream = File.Create(_sourceFilePath);
|
||||
await JsonSerializer.SerializeAsync(stream, document, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed record PlondsSourceStoreDocument(IReadOnlyList<PlondsSourceDescriptor> Sources);
|
||||
}
|
||||
104
LanMountainDesktop/Services/Plonds/PlondsVerifier.cs
Normal file
104
LanMountainDesktop/Services/Plonds/PlondsVerifier.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsVerifier
|
||||
{
|
||||
public async Task VerifyFileAsync(
|
||||
string filePath,
|
||||
IReadOnlyDictionary<string, string>? checksums,
|
||||
IEnumerable<string> checksumKeys,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("PLONDS package was not downloaded.", filePath);
|
||||
}
|
||||
|
||||
var checksum = FindChecksum(checksums, checksumKeys);
|
||||
if (checksum is null)
|
||||
{
|
||||
throw new InvalidDataException("PLONDS manifest does not declare a checksum for the package.");
|
||||
}
|
||||
|
||||
var (algorithm, expectedHash) = ParseChecksum(checksum);
|
||||
var actualHash = await ComputeHashAsync(filePath, algorithm, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"PLONDS package checksum mismatch. Expected {algorithm}:{expectedHash}, actual {algorithm}:{actualHash}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindChecksum(
|
||||
IReadOnlyDictionary<string, string>? checksums,
|
||||
IEnumerable<string> checksumKeys)
|
||||
{
|
||||
if (checksums is null || checksums.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in checksumKeys.Where(key => !string.IsNullOrWhiteSpace(key)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (checksums.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var match = checksums.FirstOrDefault(item =>
|
||||
string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(match.Value))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string Algorithm, string Hash) ParseChecksum(string checksum)
|
||||
{
|
||||
var normalized = checksum.Trim();
|
||||
var separatorIndex = normalized.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
var algorithm = normalized[..separatorIndex].Trim().ToLowerInvariant();
|
||||
var hash = NormalizeHash(normalized[(separatorIndex + 1)..]);
|
||||
if (algorithm is "md5" or "sha256" && hash.Length > 0)
|
||||
{
|
||||
return (algorithm, hash);
|
||||
}
|
||||
}
|
||||
|
||||
var inferredHash = NormalizeHash(normalized);
|
||||
return inferredHash.Length switch
|
||||
{
|
||||
32 => ("md5", inferredHash),
|
||||
64 => ("sha256", inferredHash),
|
||||
_ => throw new InvalidDataException($"Unsupported PLONDS checksum format: {checksum}")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(
|
||||
string filePath,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using HashAlgorithm hasher = algorithm switch
|
||||
{
|
||||
"md5" => MD5.Create(),
|
||||
"sha256" => SHA256.Create(),
|
||||
_ => throw new InvalidDataException($"Unsupported PLONDS checksum algorithm: {algorithm}")
|
||||
};
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeHash(string value)
|
||||
{
|
||||
return value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed class PlondsStaticUpdateService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public PlondsStaticUpdateService(string? baseUrl = null, HttpClient? httpClient = null)
|
||||
{
|
||||
_baseUrl = NormalizeBaseUrl(baseUrl ?? ResolveConfiguredBaseUrl());
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ResolveCurrentPlatform()
|
||||
{
|
||||
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 async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentVersionText = FormatVersion(currentVersion);
|
||||
var channel = includePrerelease ? UpdateSettingsValues.ChannelPreview : UpdateSettingsValues.ChannelStable;
|
||||
var platform = ResolveCurrentPlatform();
|
||||
|
||||
try
|
||||
{
|
||||
var latestUrl = BuildUrl($"meta/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest.json");
|
||||
var latest = await GetJsonAsync<LatestPointerDto>(latestUrl, cancellationToken);
|
||||
if (latest is null || string.IsNullOrWhiteSpace(latest.DistributionId))
|
||||
{
|
||||
return Failed(currentVersionText, isForce, $"PLONDS static latest manifest is unavailable at {latestUrl}.");
|
||||
}
|
||||
|
||||
var distributionUrl = BuildUrl($"meta/distributions/{Uri.EscapeDataString(latest.DistributionId)}.json");
|
||||
var distribution = await GetJsonAsync<DistributionDto>(distributionUrl, cancellationToken);
|
||||
if (distribution is null)
|
||||
{
|
||||
return Failed(currentVersionText, isForce, $"PLONDS static distribution manifest is unavailable at {distributionUrl}.");
|
||||
}
|
||||
|
||||
var latestVersionText = FirstNonEmpty(distribution.Version, latest.Version) ?? "-";
|
||||
var isNewer = TryParseVersion(latestVersionText, out var latestVersion) && latestVersion > currentVersion;
|
||||
var isUpdateAvailable = isForce || isNewer;
|
||||
var payload = isUpdateAvailable
|
||||
? CreatePayload(distribution, latest, channel, platform)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: isUpdateAvailable,
|
||||
CurrentVersionText: currentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce,
|
||||
PlondsPayload: payload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed(currentVersionText, isForce, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private PlondsUpdatePayload CreatePayload(
|
||||
DistributionDto distribution,
|
||||
LatestPointerDto latest,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
var distributionId = FirstNonEmpty(distribution.DistributionId, latest.DistributionId) ?? string.Empty;
|
||||
var fileMapUrl = FirstNonEmpty(distribution.FileMapUrl, BuildUrl($"manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json"));
|
||||
var signatureUrl = FirstNonEmpty(distribution.FileMapSignatureUrl, fileMapUrl + ".sig");
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: FirstNonEmpty(distribution.Channel, latest.Channel, channel) ?? channel,
|
||||
SubChannel: FirstNonEmpty(distribution.Platform, latest.Platform, platform) ?? platform,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapUrl,
|
||||
FileMapSignatureUrl: signatureUrl);
|
||||
}
|
||||
|
||||
private async Task<T?> GetJsonAsync<T>(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"HTTP {(int)response.StatusCode} from {url}: {Truncate(body, 256)}");
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private static UpdateCheckResult Failed(string currentVersionText, bool isForce, string message)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: currentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: message,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
private string BuildUrl(string relativePath)
|
||||
{
|
||||
return $"{_baseUrl}/{relativePath.TrimStart('/')}";
|
||||
}
|
||||
|
||||
private static string ResolveConfiguredBaseUrl()
|
||||
{
|
||||
var environmentValue = Environment.GetEnvironmentVariable(UpdateSettingsValues.PlondsStaticBaseUrlEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(environmentValue)
|
||||
? UpdateSettingsValues.DefaultPlondsStaticBaseUrl
|
||||
: environmentValue;
|
||||
}
|
||||
|
||||
private static string NormalizeBaseUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return UpdateSettingsValues.DefaultPlondsStaticBaseUrl;
|
||||
}
|
||||
|
||||
return value.Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version version)
|
||||
{
|
||||
version = new Version(0, 0, 0);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatVersion(Version version)
|
||||
{
|
||||
if (version.Revision >= 0)
|
||||
{
|
||||
return version.ToString();
|
||||
}
|
||||
|
||||
return version.Build >= 0
|
||||
? $"{version.Major}.{version.Minor}.{version.Build}"
|
||||
: $"{version.Major}.{version.Minor}";
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private sealed record LatestPointerDto(
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt);
|
||||
|
||||
private sealed record DistributionDto(
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
string? SourceVersion,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? FileMapUrl,
|
||||
string? FileMapSignatureUrl);
|
||||
}
|
||||
@@ -375,7 +375,6 @@ public interface IUpdateSettingsService
|
||||
bool TryApplyOnExit();
|
||||
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,
|
||||
|
||||
@@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Plonds;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
@@ -788,44 +789,60 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new();
|
||||
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
|
||||
private readonly IPlondsService _plondsService;
|
||||
private readonly PlondsPreparedPackageInstaller _plondsInstaller = new();
|
||||
private readonly UpdateInstallGateway _plondsUpdateInstallGateway = new();
|
||||
private readonly Lazy<UpdateOrchestrator> _orchestrator;
|
||||
private PlondsLatestResult? _pendingPlondsLatest;
|
||||
private PlondsManifestCandidate? _pendingPlondsCleanInstallCandidate;
|
||||
private UpdateManifest? _pendingPlondsInstallerManifest;
|
||||
private PlondsPreparedPackage? _pendingPlondsPackage;
|
||||
private UpdatePhase _plondsPhase = UpdatePhase.Idle;
|
||||
private bool _orchestratorEventsSubscribed;
|
||||
|
||||
public UpdateSettingsService(ISettingsService settingsService, Func<UpdateOrchestrator>? orchestratorFactory = null)
|
||||
public UpdateSettingsService(
|
||||
ISettingsService settingsService,
|
||||
Func<UpdateOrchestrator>? orchestratorFactory = null,
|
||||
IPlondsService? plondsService = null)
|
||||
{
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
_plondsService = plondsService ?? PlondsClientServiceFactory.CreateDefault();
|
||||
_orchestrator = new Lazy<UpdateOrchestrator>(
|
||||
orchestratorFactory ?? HostUpdateOrchestratorProvider.GetOrCreate,
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public UpdatePhase CurrentPhase => _orchestrator.Value.CurrentPhase;
|
||||
public UpdatePhase CurrentPhase => IsPlondsSelected()
|
||||
? _plondsPhase
|
||||
: (_orchestrator.IsValueCreated ? _orchestrator.Value.CurrentPhase : UpdatePhase.Idle);
|
||||
|
||||
public event Action<UpdatePhase>? PhaseChanged
|
||||
{
|
||||
add => _orchestrator.Value.PhaseChanged += value;
|
||||
add
|
||||
{
|
||||
_phaseChanged += value;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (_orchestrator.IsValueCreated)
|
||||
{
|
||||
_orchestrator.Value.PhaseChanged -= value;
|
||||
}
|
||||
_phaseChanged -= value;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<UpdateProgressReport>? ProgressChanged
|
||||
{
|
||||
add => _orchestrator.Value.ProgressChanged += value;
|
||||
add
|
||||
{
|
||||
_progressChanged += value;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (_orchestrator.IsValueCreated)
|
||||
{
|
||||
_orchestrator.Value.ProgressChanged -= value;
|
||||
}
|
||||
_progressChanged -= value;
|
||||
}
|
||||
}
|
||||
|
||||
private event Action<UpdatePhase>? _phaseChanged;
|
||||
private event Action<UpdateProgressReport>? _progressChanged;
|
||||
|
||||
public UpdateSettingsState Get()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
@@ -900,47 +917,77 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
|
||||
public Task<UpdateCheckReport> CheckAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.CheckAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? CheckPlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().CheckAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.DownloadAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? DownloadPlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().DownloadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<InstallResult> InstallAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.InstallAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? InstallPlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().InstallAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task RollbackAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.RollbackAsync(cancellationToken);
|
||||
return GetOrchestrator().RollbackAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task PauseAsync()
|
||||
{
|
||||
return _orchestrator.Value.PauseAsync();
|
||||
return IsPlondsSelected()
|
||||
? PausePlondsAsync()
|
||||
: GetOrchestrator().PauseAsync();
|
||||
}
|
||||
|
||||
public Task<LanMountainDesktop.Services.Update.DownloadResult> ResumeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.ResumeAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? ResumePlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().ResumeAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task CancelAsync()
|
||||
{
|
||||
return _orchestrator.Value.CancelAsync();
|
||||
if (IsPlondsSelected())
|
||||
{
|
||||
_pendingPlondsLatest = null;
|
||||
_pendingPlondsCleanInstallCandidate = null;
|
||||
_pendingPlondsInstallerManifest = null;
|
||||
_pendingPlondsPackage = null;
|
||||
TransitionPlonds(UpdatePhase.Idle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return GetOrchestrator().CancelAsync();
|
||||
}
|
||||
|
||||
public Task AutoCheckIfEnabledAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.AutoCheckIfEnabledAsync(cancellationToken);
|
||||
if (IsPlondsSelected())
|
||||
{
|
||||
return AutoCheckPlondsIfEnabledAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return GetOrchestrator().AutoCheckIfEnabledAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public bool TryApplyOnExit()
|
||||
{
|
||||
return _orchestrator.Value.TryApplyOnExit();
|
||||
if (IsPlondsSelected())
|
||||
{
|
||||
return TryApplyPlondsOnExit();
|
||||
}
|
||||
|
||||
return GetOrchestrator().TryApplyOnExit();
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
@@ -959,26 +1006,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staticResult = isForce
|
||||
? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
if (staticResult.Success && staticResult.PlondsPayload is not null)
|
||||
{
|
||||
return staticResult.PlondsPayload;
|
||||
}
|
||||
|
||||
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(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -1016,8 +1043,11 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
public void Dispose()
|
||||
{
|
||||
_githubReleaseUpdateService.Dispose();
|
||||
_plondsStaticUpdateService.Dispose();
|
||||
_plondsReleaseUpdateService.Dispose();
|
||||
if (_orchestrator.IsValueCreated && _orchestratorEventsSubscribed)
|
||||
{
|
||||
_orchestrator.Value.PhaseChanged -= OnOrchestratorPhaseChanged;
|
||||
_orchestrator.Value.ProgressChanged -= OnOrchestratorProgressChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
@@ -1026,59 +1056,533 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource);
|
||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
if (IsGitHubSelected())
|
||||
{
|
||||
return isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
|
||||
var staticResult = isForce
|
||||
? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
var result = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
return new UpdateCheckResult(
|
||||
Success: result.Success,
|
||||
IsUpdateAvailable: isForce || result.IsUpdateAvailable,
|
||||
CurrentVersionText: currentVersion.ToString(),
|
||||
LatestVersionText: result.LatestVersion?.ToString() ?? "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
if (staticResult.Success)
|
||||
private async Task<UpdateCheckReport> CheckPlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_plondsPhase.CanCheck())
|
||||
{
|
||||
return staticResult;
|
||||
return new UpdateCheckReport(false, null, null, null, null, null, null, null, null, $"Cannot check in phase {_plondsPhase}.");
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}");
|
||||
|
||||
var plondsResult = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (plondsResult.Success)
|
||||
TransitionPlonds(UpdatePhase.Checking);
|
||||
var currentVersionText = LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider.ResolveForCurrentProcess().Version;
|
||||
if (!TryParseVersion(currentVersionText, out var currentVersion))
|
||||
{
|
||||
return plondsResult;
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, $"Invalid current version text: {currentVersionText}");
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null;
|
||||
_pendingPlondsCleanInstallCandidate = _pendingPlondsLatest?.Candidates
|
||||
.FirstOrDefault(candidate => candidate.Manifest.RequiresCleanInstall);
|
||||
_pendingPlondsInstallerManifest = null;
|
||||
_pendingPlondsPackage = null;
|
||||
TransitionPlonds(UpdatePhase.Checked);
|
||||
SaveLastChecked();
|
||||
|
||||
var githubFallbackResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (githubFallbackResult.Success)
|
||||
if (!latest.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 new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
|
||||
}
|
||||
|
||||
return githubFallbackResult;
|
||||
var payloadKind = latest.IsUpdateAvailable
|
||||
? _pendingPlondsCleanInstallCandidate is not null
|
||||
? UpdatePayloadKind.FullInstaller
|
||||
: UpdatePayloadKind.DeltaPlonds
|
||||
: (UpdatePayloadKind?)null;
|
||||
|
||||
return new UpdateCheckReport(
|
||||
latest.IsUpdateAvailable,
|
||||
latest.LatestVersion?.ToString(),
|
||||
currentVersionText,
|
||||
payloadKind,
|
||||
latest.Candidates.FirstOrDefault()?.Source.Id,
|
||||
Get().UpdateChannel,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private async Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadPlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_plondsPhase is not (UpdatePhase.Checked or UpdatePhase.PausedDownloading))
|
||||
{
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, $"Cannot download in phase {_plondsPhase}.", false);
|
||||
}
|
||||
|
||||
if (_pendingPlondsLatest is null || !_pendingPlondsLatest.IsUpdateAvailable)
|
||||
{
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, "No PLONDS update is pending.", false);
|
||||
}
|
||||
|
||||
var currentVersion = _pendingPlondsLatest.CurrentVersion;
|
||||
if (_pendingPlondsCleanInstallCandidate is not null)
|
||||
{
|
||||
return await DownloadPlondsCleanInstallAsync(_pendingPlondsCleanInstallCandidate, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
TransitionPlonds(UpdatePhase.Downloading);
|
||||
var result = await _plondsService.FindAndPrepareLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Success || result.Package is null)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, result.ErrorMessage ?? "PLONDS package preparation failed.", false);
|
||||
}
|
||||
|
||||
_pendingPlondsPackage = result.Package;
|
||||
TransitionPlonds(UpdatePhase.Downloaded);
|
||||
SavePendingPlondsPackage(result.Package);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(true, result.Package.ManifestPath, null, true);
|
||||
}
|
||||
|
||||
private async Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadPlondsCleanInstallAsync(
|
||||
PlondsManifestCandidate candidate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Downloading);
|
||||
|
||||
var manifest = await ResolveGitHubInstallerManifestForPlondsAsync(candidate.Manifest, cancellationToken).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(
|
||||
false,
|
||||
null,
|
||||
$"PLONDS {candidate.Manifest.CurrentVersion} requires clean install, but no matching GitHub installer release was found.",
|
||||
false);
|
||||
}
|
||||
|
||||
var mirror = manifest.InstallerMirrors?
|
||||
.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Url));
|
||||
if (mirror is null || string.IsNullOrWhiteSpace(mirror.Url))
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(
|
||||
false,
|
||||
null,
|
||||
$"PLONDS {candidate.Manifest.CurrentVersion} requires clean install, but GitHub release has no usable installer asset.",
|
||||
false);
|
||||
}
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(mirror.Name)
|
||||
? $"{manifest.DistributionId}-{manifest.ToVersion}-installer.exe"
|
||||
: mirror.Name!;
|
||||
var asset = new GitHubReleaseAsset(fileName, mirror.Url!, mirror.Size, mirror.Sha256);
|
||||
var destinationPath = CreateInstallerDestinationPath(manifest, fileName);
|
||||
var maxThreads = UpdateSettingsValues.NormalizeDownloadThreads(Get().UpdateDownloadThreads);
|
||||
var progress = new Progress<double>(fraction =>
|
||||
{
|
||||
var downloadReport = new DownloadProgressReport(
|
||||
fileName,
|
||||
0,
|
||||
Math.Max(0, mirror.Size),
|
||||
0,
|
||||
fraction >= 1 ? 1 : 0,
|
||||
1,
|
||||
Math.Clamp(fraction, 0, 1));
|
||||
|
||||
_progressChanged?.Invoke(new UpdateProgressReport(
|
||||
UpdatePhase.Downloading,
|
||||
$"Downloading {fileName}",
|
||||
Math.Clamp(fraction, 0, 1),
|
||||
downloadReport,
|
||||
null));
|
||||
});
|
||||
|
||||
var result = await _githubReleaseUpdateService.DownloadAssetAsync(
|
||||
asset,
|
||||
destinationPath,
|
||||
UpdateSettingsValues.DownloadSourceGitHub,
|
||||
maxThreads,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Success || string.IsNullOrWhiteSpace(result.FilePath))
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(
|
||||
false,
|
||||
result.FilePath,
|
||||
result.ErrorMessage ?? "Failed to download GitHub installer for PLONDS clean install.",
|
||||
result.HashVerified);
|
||||
}
|
||||
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
DeploymentLockService.WriteLock(launcherRoot, new DeploymentLock(
|
||||
SchemaVersion: 1,
|
||||
Kind: "full",
|
||||
TargetVersion: manifest.ToVersion,
|
||||
PayloadPath: result.FilePath,
|
||||
PayloadSha256: result.ExpectedHash,
|
||||
CreatedAtUtc: DateTimeOffset.UtcNow));
|
||||
|
||||
_pendingPlondsInstallerManifest = manifest;
|
||||
_pendingPlondsPackage = null;
|
||||
TransitionPlonds(UpdatePhase.Downloaded);
|
||||
SavePendingPlondsInstaller(manifest, result.FilePath, result.ExpectedHash);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(true, result.FilePath, null, result.HashVerified);
|
||||
}
|
||||
|
||||
private Task PausePlondsAsync()
|
||||
{
|
||||
if (_plondsPhase.CanPause())
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.PausedDownloading);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<LanMountainDesktop.Services.Update.DownloadResult> ResumePlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _plondsPhase is UpdatePhase.PausedDownloading
|
||||
? await DownloadPlondsAsync(cancellationToken).ConfigureAwait(false)
|
||||
: new LanMountainDesktop.Services.Update.DownloadResult(false, null, $"Cannot resume in phase {_plondsPhase}.", false);
|
||||
}
|
||||
|
||||
private async Task<InstallResult> InstallPlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_plondsPhase.CanInstall())
|
||||
{
|
||||
return new InstallResult(false, $"Cannot install in phase {_plondsPhase}.", false, "invalid_phase");
|
||||
}
|
||||
|
||||
if (_pendingPlondsInstallerManifest is not null)
|
||||
{
|
||||
return await InstallPlondsCleanInstallAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_pendingPlondsPackage is null)
|
||||
{
|
||||
return new InstallResult(false, "No PLONDS package has been prepared.", false, "staging_incomplete");
|
||||
}
|
||||
|
||||
TransitionPlonds(UpdatePhase.Installing);
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
var progress = new Progress<InstallProgressReport>(report =>
|
||||
{
|
||||
_progressChanged?.Invoke(new UpdateProgressReport(
|
||||
UpdatePhase.Installing,
|
||||
report.Message,
|
||||
report.ProgressPercent / 100.0,
|
||||
null,
|
||||
report));
|
||||
});
|
||||
|
||||
var install = await _plondsInstaller.InstallAsync(_pendingPlondsPackage, launcherRoot, progress, cancellationToken).ConfigureAwait(false);
|
||||
if (!install.Success)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new InstallResult(false, install.ErrorMessage, false, install.ErrorCode);
|
||||
}
|
||||
|
||||
TransitionPlonds(UpdatePhase.Installed);
|
||||
return new InstallResult(true, null, false);
|
||||
}
|
||||
|
||||
private async Task<InstallResult> InstallPlondsCleanInstallAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Installing);
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
var progress = new Progress<InstallProgressReport>(report =>
|
||||
{
|
||||
_progressChanged?.Invoke(new UpdateProgressReport(
|
||||
UpdatePhase.Installing,
|
||||
report.Message,
|
||||
report.ProgressPercent / 100.0,
|
||||
null,
|
||||
report));
|
||||
});
|
||||
|
||||
var install = await _plondsUpdateInstallGateway.InstallAsync(
|
||||
UpdatePayloadKind.FullInstaller,
|
||||
launcherRoot,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!install.Success)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return install;
|
||||
}
|
||||
|
||||
TransitionPlonds(UpdatePhase.Installed);
|
||||
return install;
|
||||
}
|
||||
|
||||
private async Task AutoCheckPlondsIfEnabledAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = Get();
|
||||
if (string.Equals(UpdateSettingsValues.NormalizeMode(settings.UpdateMode), UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await CheckPlondsAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (report.IsUpdateAvailable && _plondsPhase.CanDownload())
|
||||
{
|
||||
await DownloadPlondsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPlondsSelected()
|
||||
{
|
||||
return !IsGitHubSelected();
|
||||
}
|
||||
|
||||
private bool IsGitHubSelected()
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource);
|
||||
return string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void TransitionPlonds(UpdatePhase phase)
|
||||
{
|
||||
if (_plondsPhase == phase)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_plondsPhase = phase;
|
||||
_phaseChanged?.Invoke(phase);
|
||||
_progressChanged?.Invoke(new UpdateProgressReport(phase, $"Phase changed to {phase}", 0, null, null));
|
||||
}
|
||||
|
||||
private UpdateOrchestrator GetOrchestrator()
|
||||
{
|
||||
var orchestrator = _orchestrator.Value;
|
||||
if (!_orchestratorEventsSubscribed)
|
||||
{
|
||||
orchestrator.PhaseChanged += OnOrchestratorPhaseChanged;
|
||||
orchestrator.ProgressChanged += OnOrchestratorProgressChanged;
|
||||
_orchestratorEventsSubscribed = true;
|
||||
}
|
||||
|
||||
return orchestrator;
|
||||
}
|
||||
|
||||
private void OnOrchestratorPhaseChanged(UpdatePhase phase)
|
||||
{
|
||||
_phaseChanged?.Invoke(phase);
|
||||
}
|
||||
|
||||
private void OnOrchestratorProgressChanged(UpdateProgressReport report)
|
||||
{
|
||||
_progressChanged?.Invoke(report);
|
||||
}
|
||||
|
||||
private void SaveLastChecked()
|
||||
{
|
||||
var state = Get();
|
||||
Save(state with { LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
|
||||
}
|
||||
|
||||
private void SavePendingPlondsPackage(PlondsPreparedPackage package)
|
||||
{
|
||||
var state = Get();
|
||||
Save(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = package.ManifestPath,
|
||||
PendingUpdateVersion = package.Version.ToString(),
|
||||
PendingUpdatePublishedAtUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
});
|
||||
}
|
||||
|
||||
private void SavePendingPlondsInstaller(UpdateManifest manifest, string installerPath, string? sha256)
|
||||
{
|
||||
var state = Get();
|
||||
Save(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = installerPath,
|
||||
PendingUpdateVersion = manifest.ToVersion,
|
||||
PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = string.IsNullOrWhiteSpace(sha256) ? null : sha256.Trim().ToLowerInvariant(),
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
});
|
||||
}
|
||||
|
||||
private bool TryApplyPlondsOnExit()
|
||||
{
|
||||
var settings = Get();
|
||||
if (!string.Equals(
|
||||
UpdateSettingsValues.NormalizeMode(settings.UpdateMode),
|
||||
UpdateSettingsValues.ModeSilentOnExit,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
try
|
||||
{
|
||||
if (_pendingPlondsPackage is not null)
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", "PLONDS package pending. Applying from Host on exit.");
|
||||
var result = _plondsInstaller.InstallAsync(
|
||||
_pendingPlondsPackage,
|
||||
launcherRoot,
|
||||
progress: null,
|
||||
CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
var deploymentLock = DeploymentLockService.ReadLock(launcherRoot);
|
||||
if (!string.Equals(deploymentLock?.Kind, "full", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.PendingUpdateInstallerPath) ||
|
||||
!File.Exists(settings.PendingUpdateInstallerPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AppLogger.Info("UpdateWorkflow", "PLONDS clean-install installer pending. Launching from Host Update on exit.");
|
||||
var install = _plondsUpdateInstallGateway.InstallAsync(
|
||||
UpdatePayloadKind.FullInstaller,
|
||||
launcherRoot,
|
||||
progress: null,
|
||||
CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
return install.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", "Failed to apply pending PLONDS update on exit.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UpdateManifest?> ResolveGitHubInstallerManifestForPlondsAsync(
|
||||
PlondsClientManifest plondsManifest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var tag in BuildReleaseTagCandidates(plondsManifest.CurrentVersion))
|
||||
{
|
||||
try
|
||||
{
|
||||
var release = await _githubReleaseUpdateService
|
||||
.GetReleaseByTagAsync(tag, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (release is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return UpdateManifestMapper.FromFullInstaller(release, Get().UpdateChannel, ResolveCurrentPlatform());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Failed to resolve GitHub installer release '{tag}' for PLONDS clean install: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildReleaseTagCandidates(string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var trimmed = version.Trim();
|
||||
if (trimmed.StartsWith("v", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return trimmed;
|
||||
yield return trimmed[1..];
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return $"v{trimmed}";
|
||||
yield return trimmed;
|
||||
}
|
||||
|
||||
private static string CreateInstallerDestinationPath(UpdateManifest manifest, string fileName)
|
||||
{
|
||||
var safeFileName = string.Join(
|
||||
"_",
|
||||
fileName.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries)).Trim();
|
||||
if (string.IsNullOrWhiteSpace(safeFileName))
|
||||
{
|
||||
safeFileName = $"{manifest.DistributionId}-{manifest.ToVersion}-installer.exe";
|
||||
}
|
||||
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Updates",
|
||||
safeFileName);
|
||||
}
|
||||
|
||||
private static string ResolveCurrentPlatform()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
|
||||
System.Runtime.InteropServices.Architecture.X86 => "x86",
|
||||
_ => "x64"
|
||||
};
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version version)
|
||||
{
|
||||
version = new Version(0, 0, 0);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().TrimStart('v', 'V');
|
||||
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
normalized = normalized[..separatorIndex];
|
||||
}
|
||||
|
||||
return Version.TryParse(normalized, out version!);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider, IDisposable
|
||||
{
|
||||
private readonly GitHubReleaseUpdateService _githubService;
|
||||
private readonly bool _ownsService;
|
||||
@@ -37,7 +37,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
return UpdateManifestMapper.FromGitHubRelease(result.Release, result.PlondsPayload, channel, platform);
|
||||
return UpdateManifestMapper.FromGitHubRelease(result.Release, channel, platform);
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||
@@ -53,8 +53,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
return UpdateManifestMapper.FromGitHubRelease(release, plondsPayload, channel, platform);
|
||||
return UpdateManifestMapper.FromGitHubRelease(release, channel, platform);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
@@ -67,65 +66,11 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||
}
|
||||
|
||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||
public void Dispose()
|
||||
{
|
||||
if (release.Assets is null || release.Assets.Count == 0)
|
||||
if (_ownsService)
|
||||
{
|
||||
return null;
|
||||
_githubService.Dispose();
|
||||
}
|
||||
|
||||
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(a => string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
System.Runtime.InteropServices.Architecture.X86 => "x86",
|
||||
System.Runtime.InteropServices.Architecture.Arm => "arm",
|
||||
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider, IDisposable
|
||||
{
|
||||
private const string ApiBasePath = "/api/plonds/v1";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
public string ProviderName => "plonds-api";
|
||||
|
||||
public PlondsApiManifestProvider(string baseUrl, HttpClient? httpClient = null)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl.TrimEnd('/')),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_httpClient.BaseAddress ??= new Uri(baseUrl.TrimEnd('/'));
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetLatestAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var pointer = await GetChannelPointerAsync(channel, platform, currentVersion, ct);
|
||||
if (pointer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pointer.DistributionId) ||
|
||||
string.IsNullOrWhiteSpace(pointer.Version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct);
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||
string version,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var distributionId = $"{channel}-{platform}-{version}";
|
||||
return await FetchDistributionManifestAsync(distributionId, version, channel, platform, ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version fromVersion,
|
||||
Version toVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PlondsChannelPointerDto?> GetChannelPointerAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var url = $"{ApiBasePath}/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(currentVersion.ToString())}";
|
||||
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
AppLogger.Warn("Update", $"PLONDS API latest endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<PlondsChannelPointerDto>(json, PlondsJsonOptions);
|
||||
}
|
||||
|
||||
private async Task<UpdateManifest?> FetchDistributionManifestAsync(
|
||||
string distributionId,
|
||||
string targetVersion,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var url = $"{ApiBasePath}/distributions/{Uri.EscapeDataString(distributionId)}";
|
||||
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
AppLogger.Warn("Update", $"PLONDS API distribution endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var dto = JsonSerializer.Deserialize<PlondsDistributionDto>(json, PlondsJsonOptions);
|
||||
if (dto is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapDistribution(dto, channel, platform);
|
||||
}
|
||||
|
||||
private static UpdateManifest MapDistribution(PlondsDistributionDto dto, string channel, string platform)
|
||||
{
|
||||
var files = new List<UpdateFileEntry>();
|
||||
if (dto.Components is not null)
|
||||
{
|
||||
foreach (var component in dto.Components)
|
||||
{
|
||||
if (component.Files is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var f in component.Files)
|
||||
{
|
||||
var action = FirstNonEmpty(f.Action, f.Op) ?? "add";
|
||||
var sha256 = FirstNonEmpty(f.Sha256, f.ContentHash) ?? string.Empty;
|
||||
files.Add(new UpdateFileEntry(
|
||||
Path: f.Path ?? string.Empty,
|
||||
Action: action,
|
||||
Sha256: sha256,
|
||||
Size: f.Size,
|
||||
Mode: f.Mode ?? "file-object",
|
||||
ObjectKey: f.ObjectKey,
|
||||
ObjectUrl: f.ObjectUrl,
|
||||
ArchiveSha256: f.ArchiveSha256,
|
||||
Metadata: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mirrors = dto.InstallerMirrors?.Select(m => new UpdateMirrorAsset(
|
||||
Platform: m.Platform ?? platform,
|
||||
Url: m.Url,
|
||||
Name: m.FileName,
|
||||
Sha256: m.Sha256,
|
||||
Size: m.Size)).ToArray();
|
||||
|
||||
var fileMapSignatureUrl = FirstNonEmpty(dto.FileMapSignatureUrl, dto.Signatures?.FirstOrDefault()?.Signature);
|
||||
|
||||
return new UpdateManifest(
|
||||
DistributionId: dto.DistributionId ?? string.Empty,
|
||||
FromVersion: dto.SourceVersion ?? string.Empty,
|
||||
ToVersion: dto.Version ?? string.Empty,
|
||||
Platform: platform,
|
||||
Channel: channel,
|
||||
PublishedAt: dto.PublishedAt,
|
||||
Kind: UpdatePayloadKind.DeltaPlonds,
|
||||
FileMapUrl: dto.FileMapUrl,
|
||||
FileMapSignatureUrl: fileMapSignatureUrl,
|
||||
FileMapSha256: null,
|
||||
Files: files,
|
||||
InstallerMirrors: mirrors,
|
||||
Metadata: dto.Metadata as IReadOnlyDictionary<string, string> ?? new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions PlondsJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private sealed record PlondsChannelPointerDto(
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
DateTimeOffset PublishedAt);
|
||||
|
||||
private sealed record PlondsDistributionDto(
|
||||
string? DistributionId,
|
||||
string? Version,
|
||||
string? SourceVersion,
|
||||
string? Channel,
|
||||
string? Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? FileMapUrl,
|
||||
string? FileMapSignatureUrl,
|
||||
List<PlondsComponentDto>? Components,
|
||||
List<PlondsMirrorDto>? InstallerMirrors,
|
||||
List<PlondsSignatureDto>? Signatures,
|
||||
Dictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record PlondsComponentDto(
|
||||
string? Id,
|
||||
string? Root,
|
||||
string? Mode,
|
||||
List<PlondsFileDto>? Files);
|
||||
|
||||
private sealed record PlondsFileDto(
|
||||
string? Path,
|
||||
string? Op,
|
||||
string? Action,
|
||||
string? ContentHash,
|
||||
string? Sha256,
|
||||
long Size,
|
||||
string? Mode,
|
||||
string? ObjectKey,
|
||||
string? ObjectUrl,
|
||||
string? ArchiveSha256);
|
||||
|
||||
private sealed record PlondsMirrorDto(
|
||||
string? Platform,
|
||||
string? Url,
|
||||
string? FileName,
|
||||
string? Sha256,
|
||||
long Size);
|
||||
|
||||
private sealed record PlondsSignatureDto(
|
||||
string? Algorithm,
|
||||
string? KeyId,
|
||||
string? Signature);
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class SettingsUpdateManifestProvider : IUpdateManifestProvider
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly IUpdateManifestProvider _plondsWithFallback;
|
||||
private readonly IUpdateManifestProvider _github;
|
||||
|
||||
public SettingsUpdateManifestProvider(
|
||||
ISettingsFacadeService settingsFacade,
|
||||
IUpdateManifestProvider plonds,
|
||||
IUpdateManifestProvider github)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_github = github ?? throw new ArgumentNullException(nameof(github));
|
||||
_plondsWithFallback = new CompositeManifestProvider(plonds ?? throw new ArgumentNullException(nameof(plonds)), _github);
|
||||
}
|
||||
|
||||
public string ProviderName => "settings-selected-update-source";
|
||||
|
||||
public Task<UpdateManifest?> GetLatestAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return SelectProvider().GetLatestAsync(channel, platform, currentVersion, ct);
|
||||
}
|
||||
|
||||
public Task<UpdateManifest?> GetByVersionAsync(
|
||||
string version,
|
||||
string channel,
|
||||
string platform,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return SelectProvider().GetByVersionAsync(version, channel, platform, ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
string channel,
|
||||
string platform,
|
||||
Version fromVersion,
|
||||
Version toVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return SelectProvider().GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct);
|
||||
}
|
||||
|
||||
private IUpdateManifestProvider SelectProvider()
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsFacade.Update.Get().UpdateDownloadSource);
|
||||
return string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
|
||||
? _github
|
||||
: _plondsWithFallback;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ internal sealed class UpdateInstallGateway
|
||||
0,
|
||||
0));
|
||||
|
||||
if (!VerifyDeploymentLock(payloadKind, launcherRoot, out var lockErrorCode, out var lockError))
|
||||
if (!VerifyDeploymentLock(payloadKind, launcherRoot, out var deploymentLock, out var lockErrorCode, out var lockError))
|
||||
{
|
||||
return new InstallResult(false, lockError, false, lockErrorCode);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ internal sealed class UpdateInstallGateway
|
||||
return new InstallResult(true, null, false);
|
||||
}
|
||||
|
||||
var installerPath = FindPendingInstaller(launcherRoot, payloadKind, ct);
|
||||
var installerPath = FindPendingInstaller(launcherRoot, deploymentLock!, ct);
|
||||
if (installerPath is null)
|
||||
{
|
||||
return new InstallResult(false, "No pending installer found.", false, "staging_incomplete");
|
||||
@@ -92,19 +92,25 @@ internal sealed class UpdateInstallGateway
|
||||
}
|
||||
}
|
||||
|
||||
private static bool VerifyDeploymentLock(UpdatePayloadKind payloadKind, string launcherRoot, out string? errorCode, out string? error)
|
||||
private static bool VerifyDeploymentLock(
|
||||
UpdatePayloadKind payloadKind,
|
||||
string launcherRoot,
|
||||
out DeploymentLock? deploymentLock,
|
||||
out string? errorCode,
|
||||
out string? error)
|
||||
{
|
||||
deploymentLock = null;
|
||||
errorCode = null;
|
||||
error = null;
|
||||
var deploymentLock = DeploymentLockService.ReadLock(launcherRoot);
|
||||
if (deploymentLock is null)
|
||||
var currentLock = DeploymentLockService.ReadLock(launcherRoot);
|
||||
if (currentLock is null)
|
||||
{
|
||||
errorCode = "lock_conflict";
|
||||
error = "Deployment lock is missing. Please redownload the update.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deploymentLock.SchemaVersion != 1)
|
||||
if (currentLock.SchemaVersion != 1)
|
||||
{
|
||||
errorCode = "lock_conflict";
|
||||
error = "Deployment lock schema is unsupported. Please redownload the update.";
|
||||
@@ -112,22 +118,23 @@ internal sealed class UpdateInstallGateway
|
||||
}
|
||||
|
||||
var expectedKind = payloadKind is UpdatePayloadKind.DeltaPlonds ? "delta" : "full";
|
||||
if (!string.Equals(deploymentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.Equals(currentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errorCode = "lock_conflict";
|
||||
error = "Deployment lock payload type mismatch. Please redownload the update.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) ||
|
||||
!File.Exists(deploymentLock.PayloadPath) &&
|
||||
!Directory.Exists(deploymentLock.PayloadPath))
|
||||
if (string.IsNullOrWhiteSpace(currentLock.PayloadPath) ||
|
||||
!File.Exists(currentLock.PayloadPath) &&
|
||||
!Directory.Exists(currentLock.PayloadPath))
|
||||
{
|
||||
errorCode = "staging_incomplete";
|
||||
error = "Deployment lock payload path is missing. Please redownload the update.";
|
||||
return false;
|
||||
}
|
||||
|
||||
deploymentLock = currentLock;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -240,10 +247,17 @@ internal sealed class UpdateInstallGateway
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindPendingInstaller(string launcherRoot, UpdatePayloadKind payloadKind, CancellationToken ct)
|
||||
private static string? FindPendingInstaller(string launcherRoot, DeploymentLock deploymentLock, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) &&
|
||||
File.Exists(deploymentLock.PayloadPath) &&
|
||||
Path.GetExtension(deploymentLock.PayloadPath).Equals(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return deploymentLock.PayloadPath;
|
||||
}
|
||||
|
||||
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
|
||||
if (!Directory.Exists(incomingDir))
|
||||
{
|
||||
|
||||
@@ -7,71 +7,8 @@ internal static class UpdateManifestMapper
|
||||
{
|
||||
public static UpdateManifest FromGitHubRelease(
|
||||
GitHubReleaseInfo release,
|
||||
PlondsUpdatePayload? plondsPayload,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
if (plondsPayload is not null)
|
||||
{
|
||||
return FromPlondsPayload(plondsPayload, release, channel, platform);
|
||||
}
|
||||
|
||||
return FromFullInstaller(release, channel, platform);
|
||||
}
|
||||
|
||||
public static UpdateManifest FromPlondsPayload(
|
||||
PlondsUpdatePayload payload,
|
||||
GitHubReleaseInfo release,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
var files = new List<UpdateFileEntry>();
|
||||
|
||||
if (payload.UpdateArchiveUrl is not null)
|
||||
{
|
||||
files.Add(new UpdateFileEntry(
|
||||
Path: "update.zip",
|
||||
Action: "add",
|
||||
Sha256: payload.UpdateArchiveSha256 ?? string.Empty,
|
||||
Size: payload.UpdateArchiveSizeBytes ?? 0,
|
||||
Mode: "compressed-object",
|
||||
ObjectKey: null,
|
||||
ObjectUrl: payload.UpdateArchiveUrl,
|
||||
ArchiveSha256: null,
|
||||
Metadata: null));
|
||||
}
|
||||
|
||||
var mirrors = release.Assets
|
||||
.Where(IsInstallerAsset)
|
||||
.Select(a => new UpdateMirrorAsset(
|
||||
Platform: platform,
|
||||
Url: a.BrowserDownloadUrl,
|
||||
Name: a.Name,
|
||||
Sha256: a.Sha256,
|
||||
Size: a.SizeBytes))
|
||||
.ToArray();
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "github-plonds",
|
||||
["releaseTag"] = release.TagName
|
||||
};
|
||||
|
||||
return new UpdateManifest(
|
||||
DistributionId: payload.DistributionId,
|
||||
FromVersion: string.Empty,
|
||||
ToVersion: NormalizeTagVersion(release.TagName),
|
||||
Platform: platform,
|
||||
Channel: channel,
|
||||
PublishedAt: release.PublishedAt,
|
||||
Kind: UpdatePayloadKind.DeltaPlonds,
|
||||
FileMapUrl: payload.FileMapJsonUrl,
|
||||
FileMapSignatureUrl: payload.FileMapSignatureUrl,
|
||||
FileMapSha256: null,
|
||||
Files: files,
|
||||
InstallerMirrors: mirrors,
|
||||
Metadata: metadata);
|
||||
}
|
||||
string platform) => FromFullInstaller(release, channel, platform);
|
||||
|
||||
public static UpdateManifest FromFullInstaller(
|
||||
GitHubReleaseInfo release,
|
||||
|
||||
@@ -25,8 +25,7 @@ internal static class HostUpdateOrchestratorProvider
|
||||
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop");
|
||||
var plondsProvider = new PlondsApiManifestProvider("https://api.classisland.tech");
|
||||
var manifestProvider = new SettingsUpdateManifestProvider(settingsFacade, plondsProvider, githubProvider);
|
||||
var manifestProvider = githubProvider;
|
||||
var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
var downloadEngine = new UpdateDownloadEngine(manifestProvider, new ResumableDownloadService(httpClient));
|
||||
var installGateway = new UpdateInstallGateway();
|
||||
@@ -128,7 +127,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
||||
UpdateManifest? manifest;
|
||||
try
|
||||
{
|
||||
var platform = LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
var platform = ResolveCurrentPlatform();
|
||||
manifest = settings.ForceUpdateReinstall
|
||||
? await _manifestProvider.GetByVersionAsync(
|
||||
currentVersionText,
|
||||
@@ -711,6 +710,24 @@ public sealed class UpdateOrchestrator : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ResolveCurrentPlatform()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
|
||||
System.Runtime.InteropServices.Architecture.X86 => "x86",
|
||||
_ => "x64"
|
||||
};
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
|
||||
private void OnPhaseChanged(UpdatePhase phase)
|
||||
{
|
||||
PhaseChanged?.Invoke(phase);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
@@ -88,7 +88,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
private FontWeight _weekdayFontWeight = FontWeight.SemiBold;
|
||||
private double _calendarDayFontSize = 18;
|
||||
private FontWeight _calendarDayFontWeight = FontWeight.SemiBold;
|
||||
private double _calendarTodayDotSize = 32;
|
||||
private double _calendarTodayDotSize = 38;
|
||||
private int _lunarItemCount = 3;
|
||||
private int _calendarVisibleRows = 6;
|
||||
private bool? _isNightModeApplied;
|
||||
@@ -254,7 +254,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
// 4x2 widget has less vertical space than 2x2. Compress only on 6-row months.
|
||||
var rowDensity = _calendarVisibleRows >= 6 ? 0.84 : 1.0;
|
||||
var dayFontSize = Math.Clamp(_calendarDayFontSize * rowDensity, 8, 24);
|
||||
var todayDotSize = Math.Clamp(_calendarTodayDotSize * rowDensity, 13.5, 32);
|
||||
var todayDotSize = Math.Clamp(_calendarTodayDotSize * rowDensity, 16, 46);
|
||||
|
||||
for (var day = 1; day <= daysInMonth; day++)
|
||||
{
|
||||
@@ -363,7 +363,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
|
||||
_calendarDayFontSize = Math.Clamp(15.4 * scale * densityBoost, 8, 22);
|
||||
_calendarDayFontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
|
||||
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.30, 13.5, 31);
|
||||
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.85, 16, 42);
|
||||
|
||||
var rightDensity = scale <= 0.72 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.38 ? 1.03 : 1.0;
|
||||
LunarDateTextBlock.FontSize = Math.Clamp(30 * scale * rightDensity, 14, 44);
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class PlondsCommitDeltaBuilder
|
||||
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||
var currentAppRoot = PlondsDeltaBuilder.ResolvePayloadAppRoot(currentExtractRoot, options.CurrentVersion);
|
||||
|
||||
var changedSourceFiles = GetChangedSourceFiles(options.BaselineTag, options.CurrentTag, sourceDirs);
|
||||
|
||||
@@ -76,7 +77,7 @@ public sealed class PlondsCommitDeltaBuilder
|
||||
}
|
||||
|
||||
var artifactFiles = MapSourceToArtifacts(changedSourceFiles, sourceDirs);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentAppRoot);
|
||||
|
||||
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -97,7 +98,7 @@ public sealed class PlondsCommitDeltaBuilder
|
||||
changedFilesMap[normalizedPath] = new PlondsChangedFileEntry(normalizedPath, fileHash, fingerprint.Size, hashAlgorithm);
|
||||
}
|
||||
|
||||
var changedZipPath = CreateChangedZipFromList(currentExtractRoot, artifactFiles, outputRoot, options.Platform);
|
||||
var changedZipPath = CreateChangedZipFromList(currentAppRoot, artifactFiles, outputRoot, options.Platform);
|
||||
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||
|
||||
var launcherInChanges = artifactFiles.Any(f =>
|
||||
|
||||
@@ -47,17 +47,26 @@ public sealed class PlondsDeltaBuilder
|
||||
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
|
||||
}
|
||||
|
||||
var currentAppRoot = ResolvePayloadAppRoot(currentExtractRoot, options.CurrentVersion);
|
||||
var baselineAppRoot = isFullUpdate
|
||||
? null
|
||||
: ResolvePayloadAppRoot(baselineExtractRoot, options.BaselineVersion);
|
||||
|
||||
var previousManifest = isFullUpdate
|
||||
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
: PayloadUtilities.ScanDirectory(baselineAppRoot);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentAppRoot);
|
||||
|
||||
var filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm);
|
||||
var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm);
|
||||
|
||||
var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform);
|
||||
var changedZipPath = CreateChangedZip(currentAppRoot, filesMap, outputRoot, options.Platform);
|
||||
|
||||
var launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath);
|
||||
var previousRootManifest = isFullUpdate
|
||||
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
|
||||
var currentRootManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
var launcherChanged = DetectLauncherChange(previousRootManifest, currentRootManifest, options.LauncherRelativePath);
|
||||
var requiresCleanInstall = launcherChanged && !isFullUpdate;
|
||||
|
||||
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||
@@ -216,6 +225,34 @@ public sealed class PlondsDeltaBuilder
|
||||
return !string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static string ResolvePayloadAppRoot(string extractRoot, string? version)
|
||||
{
|
||||
var resolvedRoot = Path.GetFullPath(extractRoot);
|
||||
if (File.Exists(Path.Combine(resolvedRoot, "LanMountainDesktop.exe")))
|
||||
{
|
||||
return resolvedRoot;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
var versionedAppRoot = Path.Combine(resolvedRoot, $"app-{version.Trim().TrimStart('v', 'V')}");
|
||||
if (Directory.Exists(versionedAppRoot) &&
|
||||
File.Exists(Path.Combine(versionedAppRoot, "LanMountainDesktop.exe")))
|
||||
{
|
||||
return versionedAppRoot;
|
||||
}
|
||||
}
|
||||
|
||||
var appRoots = Directory.Exists(resolvedRoot)
|
||||
? Directory.GetDirectories(resolvedRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => File.Exists(Path.Combine(path, "LanMountainDesktop.exe")))
|
||||
.OrderByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()
|
||||
: [];
|
||||
|
||||
return appRoots.FirstOrDefault() ?? resolvedRoot;
|
||||
}
|
||||
|
||||
internal static string ComputeHash(string filePath, string hashAlgorithm)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsPublishOptions(
|
||||
string ReleaseTag,
|
||||
string Repository,
|
||||
string ManifestPath,
|
||||
string ChangedZipPath,
|
||||
string FilesZipPath,
|
||||
string WorkDir,
|
||||
string S3KeyPrefix,
|
||||
PlondsS3ClientOptions S3)
|
||||
{
|
||||
public int DirectoryUploadConcurrency { get; init; } = 4;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsPublishResult(
|
||||
string ReleaseTag,
|
||||
string Version,
|
||||
string VersionPrefix,
|
||||
string ManifestKey,
|
||||
string ManifestUrl,
|
||||
string ChangedZipKey,
|
||||
string ChangedZipUrl,
|
||||
string ChangedFolderKey,
|
||||
string ChangedFolderUrl,
|
||||
string FilesZipKey,
|
||||
string FilesZipUrl,
|
||||
string FilesFolderKey,
|
||||
string FilesFolderUrl,
|
||||
int ChangedFileCount,
|
||||
int FilesFileCount);
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public async Task<PlondsPublishResult> PublishAsync(PlondsPublishOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var releaseTag = Require(options.ReleaseTag, nameof(options.ReleaseTag));
|
||||
var repository = Require(options.Repository, nameof(options.Repository));
|
||||
var manifestPath = Path.GetFullPath(Require(options.ManifestPath, nameof(options.ManifestPath)));
|
||||
var changedZipPath = Path.GetFullPath(Require(options.ChangedZipPath, nameof(options.ChangedZipPath)));
|
||||
var filesZipPath = Path.GetFullPath(Require(options.FilesZipPath, nameof(options.FilesZipPath)));
|
||||
var workDir = Path.GetFullPath(Require(options.WorkDir, nameof(options.WorkDir)));
|
||||
var version = releaseTag.TrimStart('v', 'V');
|
||||
var prefix = NormalizePrefix(options.S3KeyPrefix);
|
||||
var versionPrefix = $"{prefix}/{version}";
|
||||
var changedFolderName = $"{version}-changed";
|
||||
var filesFolderName = $"{version}-Files";
|
||||
var changedExtractRoot = Path.Combine(workDir, changedFolderName);
|
||||
var filesExtractRoot = Path.Combine(workDir, filesFolderName);
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException("PLONDS manifest not found.", manifestPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(changedZipPath))
|
||||
{
|
||||
throw new FileNotFoundException("PLONDS changed.zip not found.", changedZipPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(filesZipPath))
|
||||
{
|
||||
throw new FileNotFoundException("PLONDS files zip not found.", filesZipPath);
|
||||
}
|
||||
|
||||
var manifest = LoadManifest(manifestPath);
|
||||
PayloadUtilities.EnsureCleanDirectory(changedExtractRoot);
|
||||
ZipFile.ExtractToDirectory(changedZipPath, changedExtractRoot, overwriteFiles: true);
|
||||
PayloadUtilities.EnsureCleanDirectory(filesExtractRoot);
|
||||
ZipFile.ExtractToDirectory(filesZipPath, filesExtractRoot, overwriteFiles: true);
|
||||
|
||||
var manifestKey = $"{versionPrefix}/PLONDS.json";
|
||||
var latestManifestKey = $"{prefix}/PLONDS.json";
|
||||
var changedZipKey = $"{versionPrefix}/changed.zip";
|
||||
var changedFolderKey = $"{versionPrefix}/{changedFolderName}";
|
||||
var filesZipKey = $"{versionPrefix}/Files.zip";
|
||||
var filesFolderKey = $"{versionPrefix}/{filesFolderName}";
|
||||
|
||||
using var s3 = new PlondsS3Client(options.S3);
|
||||
|
||||
await UploadArtifactAsync(s3, changedZipPath, changedZipKey, "application/zip", cancellationToken).ConfigureAwait(false);
|
||||
await UploadArtifactAsync(s3, filesZipPath, filesZipKey, "application/zip", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var directoryConcurrency = Math.Max(1, options.DirectoryUploadConcurrency);
|
||||
var changedFileCount = await UploadDirectoryAsync(s3, changedExtractRoot, changedFolderKey, directoryConcurrency, cancellationToken).ConfigureAwait(false);
|
||||
var filesFileCount = await UploadDirectoryAsync(s3, filesExtractRoot, filesFolderKey, directoryConcurrency, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var updatedChecksums = new Dictionary<string, string>(manifest.Checksums, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["changed.zip"] = NormalizeChecksum(manifest.Checksums, "changed.zip", changedZipPath),
|
||||
["Files.zip"] = $"md5:{ComputeMd5Hex(filesZipPath)}"
|
||||
};
|
||||
|
||||
var updatedManifest = manifest with
|
||||
{
|
||||
Checksums = updatedChecksums,
|
||||
Downloads = new PlondsDownloadInfo(
|
||||
ReleaseTag: releaseTag,
|
||||
GitHub: new PlondsGitHubDownloadInfo(
|
||||
ReleaseUrl: $"https://github.com/{repository}/releases/tag/{releaseTag}",
|
||||
ManifestUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/PLONDS.json",
|
||||
ChangedZipUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/changed.zip",
|
||||
FilesZipUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/{Path.GetFileName(filesZipPath)}"),
|
||||
S3: new PlondsS3DownloadInfo(
|
||||
Bucket: options.S3.Bucket,
|
||||
Prefix: versionPrefix,
|
||||
ManifestKey: manifestKey,
|
||||
ManifestUrl: s3.BuildPublicUrl(manifestKey),
|
||||
ChangedZipKey: changedZipKey,
|
||||
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
|
||||
ChangedFolderKey: changedFolderKey,
|
||||
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey),
|
||||
FilesZipKey: filesZipKey,
|
||||
FilesZipUrl: s3.BuildPublicUrl(filesZipKey),
|
||||
FilesFolderKey: filesFolderKey,
|
||||
FilesFolderUrl: s3.BuildPublicUrl(filesFolderKey)))
|
||||
};
|
||||
|
||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(updatedManifest, JsonOptions), new UTF8Encoding(false));
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, manifestKey, "application/json"), cancellationToken).ConfigureAwait(false);
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, latestManifestKey, "application/json"), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await s3.EnsureObjectExistsAsync(manifestKey, cancellationToken).ConfigureAwait(false);
|
||||
await s3.EnsureObjectExistsAsync(latestManifestKey, cancellationToken).ConfigureAwait(false);
|
||||
await s3.EnsureObjectExistsAsync(changedZipKey, cancellationToken).ConfigureAwait(false);
|
||||
await s3.EnsureObjectExistsAsync(filesZipKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new PlondsPublishResult(
|
||||
ReleaseTag: releaseTag,
|
||||
Version: version,
|
||||
VersionPrefix: versionPrefix,
|
||||
ManifestKey: manifestKey,
|
||||
ManifestUrl: s3.BuildPublicUrl(manifestKey),
|
||||
ChangedZipKey: changedZipKey,
|
||||
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
|
||||
ChangedFolderKey: changedFolderKey,
|
||||
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey),
|
||||
FilesZipKey: filesZipKey,
|
||||
FilesZipUrl: s3.BuildPublicUrl(filesZipKey),
|
||||
FilesFolderKey: filesFolderKey,
|
||||
FilesFolderUrl: s3.BuildPublicUrl(filesFolderKey),
|
||||
ChangedFileCount: changedFileCount,
|
||||
FilesFileCount: filesFileCount);
|
||||
}
|
||||
|
||||
private static async Task<int> UploadDirectoryAsync(
|
||||
PlondsS3Client s3,
|
||||
string sourceDirectory,
|
||||
string destinationKeyPrefix,
|
||||
int concurrency,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var files = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
|
||||
.Select(filePath =>
|
||||
{
|
||||
var relativePath = PayloadUtilities.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, filePath));
|
||||
return new DirectoryUploadPlan(
|
||||
SourcePath: filePath,
|
||||
ObjectKey: $"{destinationKeyPrefix}/{relativePath}",
|
||||
ContentType: ResolveContentType(filePath));
|
||||
})
|
||||
.OrderBy(x => x.ObjectKey, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (files.Length == 0)
|
||||
{
|
||||
Console.WriteLine($"No files found under {sourceDirectory}; skipping S3 directory upload to {destinationKeyPrefix}.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Uploading S3 directory {destinationKeyPrefix}: {files.Length} files with concurrency {concurrency}.");
|
||||
|
||||
var processed = 0;
|
||||
var uploaded = 0;
|
||||
var skipped = 0;
|
||||
await Parallel.ForEachAsync(
|
||||
files,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = concurrency,
|
||||
CancellationToken = cancellationToken
|
||||
},
|
||||
async (file, token) =>
|
||||
{
|
||||
var didUpload = await s3.UploadFileIfChangedAsync(
|
||||
new PlondsS3ObjectUpload(file.SourcePath, file.ObjectKey, file.ContentType),
|
||||
token).ConfigureAwait(false);
|
||||
|
||||
if (didUpload)
|
||||
{
|
||||
Interlocked.Increment(ref uploaded);
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref skipped);
|
||||
}
|
||||
|
||||
var current = Interlocked.Increment(ref processed);
|
||||
if (current == files.Length || current % 10 == 0)
|
||||
{
|
||||
Console.WriteLine($"S3 directory progress {destinationKeyPrefix}: {current}/{files.Length} processed ({uploaded} uploaded, {skipped} skipped).");
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"Finished S3 directory {destinationKeyPrefix}: {files.Length} files processed ({uploaded} uploaded, {skipped} skipped).");
|
||||
return files.Length;
|
||||
}
|
||||
|
||||
private static async Task UploadArtifactAsync(
|
||||
PlondsS3Client s3,
|
||||
string sourcePath,
|
||||
string objectKey,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var didUpload = await s3.UploadFileIfChangedAsync(
|
||||
new PlondsS3ObjectUpload(sourcePath, objectKey, contentType),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine(didUpload
|
||||
? $"Published S3 artifact {objectKey}."
|
||||
: $"S3 artifact {objectKey} already exists with matching size.");
|
||||
}
|
||||
|
||||
private static PlondsManifest LoadManifest(string manifestPath)
|
||||
{
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
return JsonSerializer.Deserialize<PlondsManifest>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("PLONDS manifest is empty or invalid.");
|
||||
}
|
||||
|
||||
private static string NormalizeChecksum(
|
||||
IReadOnlyDictionary<string, string> checksums,
|
||||
string key,
|
||||
string filePath)
|
||||
{
|
||||
return checksums.TryGetValue(key, out var checksum) && !string.IsNullOrWhiteSpace(checksum)
|
||||
? checksum
|
||||
: $"md5:{ComputeMd5Hex(filePath)}";
|
||||
}
|
||||
|
||||
private static string ComputeMd5Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizePrefix(string value)
|
||||
{
|
||||
var normalized = Require(value, nameof(value)).Replace('\\', '/').Trim('/');
|
||||
if (normalized.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Invalid S3 key prefix: {value}", nameof(value));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string path)
|
||||
{
|
||||
return Path.GetExtension(path).ToLowerInvariant() switch
|
||||
{
|
||||
".json" => "application/json",
|
||||
".zip" => "application/zip",
|
||||
".dll" => "application/octet-stream",
|
||||
".exe" => "application/octet-stream",
|
||||
".pdb" => "application/octet-stream",
|
||||
".deps" => "application/json",
|
||||
".runtimeconfig" => "application/json",
|
||||
".txt" => "text/plain",
|
||||
".xml" => "application/xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Require(string value, string name)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException($"{name} is required.", name)
|
||||
: value.Trim();
|
||||
}
|
||||
|
||||
private sealed record DirectoryUploadPlan(
|
||||
string SourcePath,
|
||||
string ObjectKey,
|
||||
string ContentType);
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsS3Client : IDisposable
|
||||
{
|
||||
private const string ServiceName = "s3";
|
||||
private const string EmptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
private readonly PlondsS3ClientOptions options;
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly bool ownsHttpClient;
|
||||
|
||||
public PlondsS3Client(PlondsS3ClientOptions options, HttpClient? httpClient = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
this.options = options with
|
||||
{
|
||||
Endpoint = NormalizeEndpoint(options.Endpoint),
|
||||
Region = Require(options.Region, nameof(options.Region)),
|
||||
Bucket = Require(options.Bucket, nameof(options.Bucket)),
|
||||
AccessKey = Require(options.AccessKey, nameof(options.AccessKey)),
|
||||
SecretKey = Require(options.SecretKey, nameof(options.SecretKey)),
|
||||
PublicBaseUrl = Require(options.PublicBaseUrl, nameof(options.PublicBaseUrl)).TrimEnd('/'),
|
||||
PublicBaseKeyPrefix = NormalizeOptionalKeyPrefix(options.PublicBaseKeyPrefix),
|
||||
RequestTimeout = options.RequestTimeout <= TimeSpan.Zero ? TimeSpan.FromMinutes(30) : options.RequestTimeout,
|
||||
MaxUploadAttempts = Math.Max(1, options.MaxUploadAttempts),
|
||||
MultipartThresholdBytes = Math.Max(5L * 1024 * 1024, options.MultipartThresholdBytes),
|
||||
MultipartPartSizeBytes = Math.Max(5L * 1024 * 1024, options.MultipartPartSizeBytes),
|
||||
MultipartConcurrency = Math.Max(1, options.MultipartConcurrency)
|
||||
};
|
||||
|
||||
ownsHttpClient = httpClient is null;
|
||||
this.httpClient = httpClient ?? new HttpClient
|
||||
{
|
||||
Timeout = this.options.RequestTimeout
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UploadFileAsync(PlondsS3ObjectUpload upload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(upload);
|
||||
|
||||
var sourcePath = Path.GetFullPath(upload.SourcePath);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException("S3 upload source file not found.", sourcePath);
|
||||
}
|
||||
|
||||
var key = NormalizeKey(upload.Key);
|
||||
var payloadHash = PayloadUtilities.ComputeSha256(sourcePath);
|
||||
var contentLength = new FileInfo(sourcePath).Length;
|
||||
|
||||
if (contentLength >= options.MultipartThresholdBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UploadFileMultipartAsync(sourcePath, key, upload.ContentType, contentLength, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine($"S3 multipart upload failed for {key}; falling back to single PUT. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
for (var attempt = 1; attempt <= options.MaxUploadAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UploadFileOnceAsync(sourcePath, key, upload.ContentType, payloadHash, contentLength, attempt, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (attempt < options.MaxUploadAttempts && IsRetriable(ex))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt)));
|
||||
Console.Error.WriteLine($"S3 upload retry {attempt + 1}/{options.MaxUploadAttempts} for {key} after {delay.TotalSeconds:0}s: {ex.Message}");
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadFileMultipartAsync(
|
||||
string sourcePath,
|
||||
string key,
|
||||
string? contentType,
|
||||
long contentLength,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uploadId = await CreateMultipartUploadAsync(key, contentType, cancellationToken).ConfigureAwait(false);
|
||||
var partCount = checked((int)((contentLength + options.MultipartPartSizeBytes - 1) / options.MultipartPartSizeBytes));
|
||||
var parts = new PlondsS3UploadedPart[partCount];
|
||||
|
||||
Console.WriteLine($"Uploading S3 object {key} ({FormatBytes(contentLength)}) using multipart upload {uploadId}: {partCount} parts, part size {FormatBytes(options.MultipartPartSizeBytes)}, concurrency {options.MultipartConcurrency}.");
|
||||
|
||||
try
|
||||
{
|
||||
var completed = 0;
|
||||
await Parallel.ForEachAsync(
|
||||
Enumerable.Range(1, partCount),
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = options.MultipartConcurrency,
|
||||
CancellationToken = cancellationToken
|
||||
},
|
||||
async (partNumber, token) =>
|
||||
{
|
||||
var offset = (long)(partNumber - 1) * options.MultipartPartSizeBytes;
|
||||
var length = Math.Min(options.MultipartPartSizeBytes, contentLength - offset);
|
||||
parts[partNumber - 1] = await UploadMultipartPartWithRetriesAsync(
|
||||
sourcePath,
|
||||
key,
|
||||
uploadId,
|
||||
partNumber,
|
||||
offset,
|
||||
length,
|
||||
token).ConfigureAwait(false);
|
||||
|
||||
var done = Interlocked.Increment(ref completed);
|
||||
Console.WriteLine($"S3 multipart progress {key}: {done}/{partCount} parts uploaded.");
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await CompleteMultipartUploadAsync(key, uploadId, parts, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine($"Uploaded S3 object {key} using multipart upload.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await AbortMultipartUploadBestEffortAsync(key, uploadId, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> CreateMultipartUploadAsync(string key, string? contentType, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestUri = BuildObjectUri(key, "uploads=");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
}
|
||||
|
||||
SignRequest(request, key, EmptyPayloadHash, DateTimeOffset.UtcNow);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"S3 create multipart upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
|
||||
}
|
||||
|
||||
var uploadId = XDocument.Parse(body).Descendants().FirstOrDefault(element => element.Name.LocalName == "UploadId")?.Value;
|
||||
return string.IsNullOrWhiteSpace(uploadId)
|
||||
? throw new InvalidOperationException($"S3 create multipart upload response did not include UploadId for {key}.")
|
||||
: uploadId;
|
||||
}
|
||||
|
||||
private async Task<PlondsS3UploadedPart> UploadMultipartPartWithRetriesAsync(
|
||||
string sourcePath,
|
||||
string key,
|
||||
string uploadId,
|
||||
int partNumber,
|
||||
long offset,
|
||||
long length,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; attempt <= options.MaxUploadAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await UploadMultipartPartOnceAsync(
|
||||
sourcePath,
|
||||
key,
|
||||
uploadId,
|
||||
partNumber,
|
||||
offset,
|
||||
length,
|
||||
attempt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < options.MaxUploadAttempts && IsRetriable(ex))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt)));
|
||||
Console.Error.WriteLine($"S3 multipart retry {attempt + 1}/{options.MaxUploadAttempts} for {key} part {partNumber} after {delay.TotalSeconds:0}s: {ex.Message}");
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"S3 multipart upload failed for {key} part {partNumber}.");
|
||||
}
|
||||
|
||||
private async Task<PlondsS3UploadedPart> UploadMultipartPartOnceAsync(
|
||||
string sourcePath,
|
||||
string key,
|
||||
string uploadId,
|
||||
int partNumber,
|
||||
long offset,
|
||||
long length,
|
||||
int attempt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestUri = BuildObjectUri(key, $"partNumber={partNumber}&uploadId={Uri.EscapeDataString(uploadId)}");
|
||||
var bytes = new byte[length];
|
||||
await using (var fileStream = File.OpenRead(sourcePath))
|
||||
{
|
||||
fileStream.Seek(offset, SeekOrigin.Begin);
|
||||
var totalRead = 0;
|
||||
while (totalRead < bytes.Length)
|
||||
{
|
||||
var read = await fileStream.ReadAsync(bytes.AsMemory(totalRead), cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException($"Unexpected end of file while reading {sourcePath} for part {partNumber}.");
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
}
|
||||
|
||||
var payloadHash = Sha256Hex(bytes);
|
||||
Console.WriteLine($"Uploading S3 multipart part {partNumber} for {key} ({FormatBytes(length)}), attempt {attempt}/{options.MaxUploadAttempts}.");
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentLength = length;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
SignRequest(request, key, payloadHash, DateTimeOffset.UtcNow);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"S3 multipart upload failed for {key} part {partNumber}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
|
||||
}
|
||||
|
||||
var etag = response.Headers.ETag?.Tag;
|
||||
if (string.IsNullOrWhiteSpace(etag))
|
||||
{
|
||||
throw new InvalidOperationException($"S3 multipart upload did not return ETag for {key} part {partNumber}.");
|
||||
}
|
||||
|
||||
return new PlondsS3UploadedPart(partNumber, etag);
|
||||
}
|
||||
|
||||
private async Task CompleteMultipartUploadAsync(
|
||||
string key,
|
||||
string uploadId,
|
||||
IReadOnlyList<PlondsS3UploadedPart> parts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = BuildCompleteMultipartUploadBody(parts);
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(body);
|
||||
var payloadHash = Sha256Hex(bodyBytes);
|
||||
var requestUri = BuildObjectUri(key, $"uploadId={Uri.EscapeDataString(uploadId)}");
|
||||
|
||||
using var content = new ByteArrayContent(bodyBytes);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/xml");
|
||||
content.Headers.ContentLength = bodyBytes.Length;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
SignRequest(request, key, payloadHash, DateTimeOffset.UtcNow);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"S3 complete multipart upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(responseBody, 512)}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AbortMultipartUploadBestEffortAsync(string key, string uploadId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestUri = BuildObjectUri(key, $"uploadId={Uri.EscapeDataString(uploadId)}");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
|
||||
SignRequest(request, key, EmptyPayloadHash, DateTimeOffset.UtcNow);
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"S3 abort multipart upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"S3 abort multipart upload failed for {key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UploadFileIfChangedAsync(PlondsS3ObjectUpload upload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(upload);
|
||||
|
||||
var sourcePath = Path.GetFullPath(upload.SourcePath);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException("S3 upload source file not found.", sourcePath);
|
||||
}
|
||||
|
||||
var key = NormalizeKey(upload.Key);
|
||||
var contentLength = new FileInfo(sourcePath).Length;
|
||||
var existing = await TryGetObjectInfoForUploadAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (existing?.ContentLength == contentLength)
|
||||
{
|
||||
Console.WriteLine($"Skipping S3 object {key}; existing object has matching size {FormatBytes(contentLength)}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
await UploadFileAsync(upload, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task UploadFileOnceAsync(
|
||||
string sourcePath,
|
||||
string key,
|
||||
string? contentType,
|
||||
string payloadHash,
|
||||
long contentLength,
|
||||
int attempt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var requestUri = BuildObjectUri(key);
|
||||
Console.WriteLine($"Uploading S3 object {key} ({FormatBytes(contentLength)}), attempt {attempt}/{options.MaxUploadAttempts}.");
|
||||
|
||||
await using var fileStream = File.OpenRead(sourcePath);
|
||||
using var content = new StreamContent(fileStream);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType)
|
||||
? "application/octet-stream"
|
||||
: contentType);
|
||||
content.Headers.ContentLength = contentLength;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
SignRequest(request, key, payloadHash, now);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"S3 upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Uploaded S3 object {key}.");
|
||||
}
|
||||
|
||||
public async Task EnsureObjectExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedKey = NormalizeKey(key);
|
||||
var objectInfo = await TryGetObjectInfoAsync(normalizedKey, cancellationToken).ConfigureAwait(false);
|
||||
if (objectInfo is null)
|
||||
{
|
||||
throw new InvalidOperationException($"S3 object verification failed for {normalizedKey}: object was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlondsS3ObjectInfo?> TryGetObjectInfoAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedKey = NormalizeKey(key);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var requestUri = BuildObjectUri(normalizedKey);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, requestUri);
|
||||
SignRequest(request, normalizedKey, EmptyPayloadHash, now);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode is HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"S3 object metadata lookup failed for {normalizedKey}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||
}
|
||||
|
||||
return new PlondsS3ObjectInfo(
|
||||
Key: normalizedKey,
|
||||
ContentLength: response.Content.Headers.ContentLength,
|
||||
ETag: response.Headers.ETag?.Tag);
|
||||
}
|
||||
|
||||
private async Task<PlondsS3ObjectInfo?> TryGetObjectInfoForUploadAsync(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await TryGetObjectInfoAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine($"S3 object metadata lookup for {key} failed; uploading anyway. {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public string BuildPublicUrl(string key)
|
||||
{
|
||||
var normalizedKey = NormalizeKey(key);
|
||||
if (!string.IsNullOrWhiteSpace(options.PublicBaseKeyPrefix) &&
|
||||
(string.Equals(normalizedKey, options.PublicBaseKeyPrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedKey.StartsWith($"{options.PublicBaseKeyPrefix}/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
normalizedKey = normalizedKey[options.PublicBaseKeyPrefix.Length..].TrimStart('/');
|
||||
}
|
||||
|
||||
return $"{options.PublicBaseUrl}/{normalizedKey}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (ownsHttpClient)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void SignRequest(HttpRequestMessage request, string key, string payloadHash, DateTimeOffset now)
|
||||
{
|
||||
var amzDate = now.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
|
||||
var dateStamp = now.UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
var credentialScope = $"{dateStamp}/{options.Region}/{ServiceName}/aws4_request";
|
||||
var canonicalUri = BuildCanonicalUri(key);
|
||||
var canonicalQueryString = BuildCanonicalQueryString(request.RequestUri);
|
||||
var host = request.RequestUri?.IsDefaultPort == true
|
||||
? request.RequestUri.Host
|
||||
: request.RequestUri?.Authority;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot sign an S3 request without a host.");
|
||||
}
|
||||
|
||||
request.Headers.Host = host;
|
||||
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
|
||||
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", payloadHash);
|
||||
|
||||
var canonicalHeaders = new StringBuilder();
|
||||
canonicalHeaders.Append("host:").Append(host).Append('\n');
|
||||
canonicalHeaders.Append("x-amz-content-sha256:").Append(payloadHash).Append('\n');
|
||||
canonicalHeaders.Append("x-amz-date:").Append(amzDate).Append('\n');
|
||||
|
||||
var signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
||||
var canonicalRequest = string.Join('\n',
|
||||
[
|
||||
request.Method.Method,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders.ToString(),
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
]);
|
||||
|
||||
var stringToSign = string.Join('\n',
|
||||
[
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
credentialScope,
|
||||
Sha256Hex(canonicalRequest)
|
||||
]);
|
||||
|
||||
var signingKey = GetSignatureKey(options.SecretKey, dateStamp, options.Region, ServiceName);
|
||||
var signature = HmacSha256Hex(signingKey, stringToSign);
|
||||
var authorization = $"AWS4-HMAC-SHA256 Credential={options.AccessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
|
||||
request.Headers.TryAddWithoutValidation("Authorization", authorization);
|
||||
}
|
||||
|
||||
private Uri BuildObjectUri(string key, string? query = null)
|
||||
{
|
||||
var bucketPrefix = Uri.EscapeDataString(options.Bucket).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
|
||||
var path = $"{options.Endpoint.AbsolutePath.TrimEnd('/')}/{bucketPrefix}/{BuildCanonicalKey(key)}";
|
||||
var builder = new UriBuilder(options.Endpoint)
|
||||
{
|
||||
Path = path,
|
||||
Query = query ?? string.Empty
|
||||
};
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
private string BuildCanonicalUri(string key)
|
||||
{
|
||||
var bucketPrefix = Uri.EscapeDataString(options.Bucket).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
|
||||
return $"{options.Endpoint.AbsolutePath.TrimEnd('/')}/{bucketPrefix}/{BuildCanonicalKey(key)}";
|
||||
}
|
||||
|
||||
private static string BuildCanonicalKey(string key)
|
||||
{
|
||||
return string.Join("/", NormalizeKey(key)
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
}
|
||||
|
||||
private static string BuildCanonicalQueryString(Uri? uri)
|
||||
{
|
||||
if (uri is null || string.IsNullOrEmpty(uri.Query))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join("&", uri.Query.TrimStart('?')
|
||||
.Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(parameter =>
|
||||
{
|
||||
var parts = parameter.Split('=', 2);
|
||||
var name = Uri.UnescapeDataString(parts[0]);
|
||||
var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||
return new KeyValuePair<string, string>(name, value);
|
||||
})
|
||||
.OrderBy(parameter => parameter.Key, StringComparer.Ordinal)
|
||||
.ThenBy(parameter => parameter.Value, StringComparer.Ordinal)
|
||||
.Select(parameter => $"{Uri.EscapeDataString(parameter.Key)}={Uri.EscapeDataString(parameter.Value)}"));
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string value)
|
||||
{
|
||||
var normalized = value.Replace('\\', '/').Trim('/');
|
||||
if (string.IsNullOrWhiteSpace(normalized) || normalized.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Invalid S3 object key: {value}", nameof(value));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeOptionalKeyPrefix(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return NormalizeKey(value);
|
||||
}
|
||||
|
||||
private static Uri NormalizeEndpoint(Uri endpoint)
|
||||
{
|
||||
if (!endpoint.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("S3 endpoint must be an absolute URI.", nameof(endpoint));
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(endpoint)
|
||||
{
|
||||
Path = endpoint.AbsolutePath.TrimEnd('/')
|
||||
};
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
private static string Require(string value, string name)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException($"{name} is required.", name)
|
||||
: value.Trim();
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string value)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sha256Hex(byte[] value)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string BuildCompleteMultipartUploadBody(IEnumerable<PlondsS3UploadedPart> parts)
|
||||
{
|
||||
var document = new XDocument(
|
||||
new XElement("CompleteMultipartUpload",
|
||||
parts.OrderBy(part => part.PartNumber)
|
||||
.Select(part => new XElement("Part",
|
||||
new XElement("PartNumber", part.PartNumber.ToString(CultureInfo.InvariantCulture)),
|
||||
new XElement("ETag", part.ETag)))));
|
||||
|
||||
return document.ToString(SaveOptions.DisableFormatting);
|
||||
}
|
||||
|
||||
private static byte[] HmacSha256(byte[] key, string data)
|
||||
{
|
||||
return HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
private static string HmacSha256Hex(byte[] key, string data)
|
||||
{
|
||||
return Convert.ToHexString(HmacSha256(key, data)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static byte[] GetSignatureKey(string key, string dateStamp, string regionName, string serviceName)
|
||||
{
|
||||
var kDate = HmacSha256(Encoding.UTF8.GetBytes($"AWS4{key}"), dateStamp);
|
||||
var kRegion = HmacSha256(kDate, regionName);
|
||||
var kService = HmacSha256(kRegion, serviceName);
|
||||
return HmacSha256(kService, "aws4_request");
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
|
||||
private static bool IsRetriable(Exception exception)
|
||||
{
|
||||
if (exception is TaskCanceledException or TimeoutException or HttpRequestException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return exception.InnerException is not null && IsRetriable(exception.InnerException);
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] units = ["B", "KB", "MB", "GB"];
|
||||
double value = bytes;
|
||||
var unit = 0;
|
||||
while (value >= 1024 && unit < units.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return $"{value:0.##} {units[unit]}";
|
||||
}
|
||||
|
||||
private sealed record PlondsS3UploadedPart(int PartNumber, string ETag);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsS3ClientOptions(
|
||||
Uri Endpoint,
|
||||
string Region,
|
||||
string Bucket,
|
||||
string AccessKey,
|
||||
string SecretKey,
|
||||
string PublicBaseUrl,
|
||||
string PublicBaseKeyPrefix = "")
|
||||
{
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
public int MaxUploadAttempts { get; init; } = 3;
|
||||
|
||||
public long MultipartThresholdBytes { get; init; } = 8L * 1024 * 1024;
|
||||
|
||||
public long MultipartPartSizeBytes { get; init; } = 8L * 1024 * 1024;
|
||||
|
||||
public int MultipartConcurrency { get; init; } = 4;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsS3ObjectInfo(
|
||||
string Key,
|
||||
long? ContentLength,
|
||||
string? ETag);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsS3ObjectUpload(
|
||||
string SourcePath,
|
||||
string Key,
|
||||
string ContentType);
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsDownloadInfo(
|
||||
string ReleaseTag,
|
||||
[property: JsonPropertyName("github")]
|
||||
PlondsGitHubDownloadInfo GitHub,
|
||||
PlondsS3DownloadInfo S3);
|
||||
|
||||
public sealed record PlondsGitHubDownloadInfo(
|
||||
string ReleaseUrl,
|
||||
string ManifestUrl,
|
||||
string ChangedZipUrl,
|
||||
string FilesZipUrl);
|
||||
|
||||
public sealed record PlondsS3DownloadInfo(
|
||||
string Bucket,
|
||||
string Prefix,
|
||||
string ManifestKey,
|
||||
string ManifestUrl,
|
||||
string ChangedZipKey,
|
||||
string ChangedZipUrl,
|
||||
string ChangedFolderKey,
|
||||
string ChangedFolderUrl,
|
||||
string FilesZipKey,
|
||||
string FilesZipUrl,
|
||||
string FilesFolderKey,
|
||||
string FilesFolderUrl);
|
||||
@@ -11,4 +11,6 @@ public sealed record PlondsManifest(
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||
IReadOnlyDictionary<string, string> Checksums);
|
||||
IReadOnlyDictionary<string, string> Checksums,
|
||||
PlondsDownloadInfo? Downloads = null,
|
||||
IReadOnlyList<PlondsSourceDescriptor>? Sources = null);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsSourceDescriptor(
|
||||
string Id,
|
||||
string Kind,
|
||||
string ManifestUrl,
|
||||
int Priority = 0);
|
||||
@@ -4,12 +4,12 @@ return await PlondsCli.RunAsync(args);
|
||||
|
||||
internal static class PlondsCli
|
||||
{
|
||||
public static Task<int> RunAsync(string[] args)
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return Task.FromResult(1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var command = args[0].Trim().ToLowerInvariant();
|
||||
@@ -21,23 +21,25 @@ internal static class PlondsCli
|
||||
{
|
||||
case "build-delta":
|
||||
RunBuildDelta(options);
|
||||
return Task.FromResult(0);
|
||||
return 0;
|
||||
case "build-delta-from-commits":
|
||||
RunBuildDeltaFromCommits(options);
|
||||
return Task.FromResult(0);
|
||||
return 0;
|
||||
case "publish-s3":
|
||||
return await RunPublishS3Async(options).ConfigureAwait(false);
|
||||
case "pack-payload":
|
||||
RunPackPayload(options);
|
||||
return Task.FromResult(0);
|
||||
return 0;
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
PrintUsage();
|
||||
return Task.FromResult(1);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return Task.FromResult(1);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +95,46 @@ internal static class PlondsCli
|
||||
Console.WriteLine(outputZip);
|
||||
}
|
||||
|
||||
private static async Task<int> RunPublishS3Async(Dictionary<string, string> options)
|
||||
{
|
||||
var publisher = new PlondsPublisher();
|
||||
var result = await publisher.PublishAsync(new PlondsPublishOptions(
|
||||
ReleaseTag: Require(options, "release-tag"),
|
||||
Repository: Require(options, "repository"),
|
||||
ManifestPath: Require(options, "manifest"),
|
||||
ChangedZipPath: Require(options, "changed-zip"),
|
||||
FilesZipPath: Require(options, "files-zip"),
|
||||
WorkDir: Get(options, "work-dir", "plonds-publish-work") ?? "plonds-publish-work",
|
||||
S3KeyPrefix: Get(options, "s3-prefix", "lanmountain/update/plonds") ?? "lanmountain/update/plonds",
|
||||
S3: new PlondsS3ClientOptions(
|
||||
Endpoint: new Uri(Require(options, "s3-endpoint"), UriKind.Absolute),
|
||||
Region: Get(options, "s3-region", "us-east-1") ?? "us-east-1",
|
||||
Bucket: Require(options, "s3-bucket"),
|
||||
AccessKey: Require(options, "s3-access-key"),
|
||||
SecretKey: Require(options, "s3-secret-key"),
|
||||
PublicBaseUrl: Require(options, "s3-public-base-url"),
|
||||
PublicBaseKeyPrefix: Get(options, "s3-public-base-key-prefix", string.Empty) ?? string.Empty)
|
||||
{
|
||||
MultipartThresholdBytes = GetLong(options, "multipart-threshold-mb", 8) * 1024 * 1024,
|
||||
MultipartPartSizeBytes = GetLong(options, "multipart-part-size-mb", 8) * 1024 * 1024,
|
||||
MultipartConcurrency = GetInt(options, "multipart-concurrency", 4)
|
||||
})
|
||||
{
|
||||
DirectoryUploadConcurrency = GetInt(options, "directory-upload-concurrency", 4)
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"Published PLONDS release {result.ReleaseTag}:");
|
||||
Console.WriteLine($" Prefix: {result.VersionPrefix}");
|
||||
Console.WriteLine($" Manifest: {result.ManifestUrl}");
|
||||
Console.WriteLine($" ChangedZip: {result.ChangedZipUrl}");
|
||||
Console.WriteLine($" ChangedFolder: {result.ChangedFolderUrl}");
|
||||
Console.WriteLine($" FilesZip: {result.FilesZipUrl}");
|
||||
Console.WriteLine($" FilesFolder: {result.FilesFolderUrl}");
|
||||
Console.WriteLine($" ChangedFileCount: {result.ChangedFileCount}");
|
||||
Console.WriteLine($" FilesFileCount: {result.FilesFileCount}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -163,5 +205,49 @@ internal static class PlondsCli
|
||||
Console.WriteLine(" pack-payload Pack a directory into a payload zip");
|
||||
Console.WriteLine(" --source-dir <dir> Source directory");
|
||||
Console.WriteLine(" --output-zip <file> Output zip path");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" publish-s3 Publish PLONDS.json and changed.zip to S3");
|
||||
Console.WriteLine(" --release-tag <tag> GitHub release tag");
|
||||
Console.WriteLine(" --repository <owner/repo> GitHub repository");
|
||||
Console.WriteLine(" --manifest <file> PLONDS.json path");
|
||||
Console.WriteLine(" --changed-zip <file> changed.zip path");
|
||||
Console.WriteLine(" --files-zip <file> Full files zip path");
|
||||
Console.WriteLine(" --s3-endpoint <url> S3-compatible endpoint");
|
||||
Console.WriteLine(" --s3-region <region> S3 signing region");
|
||||
Console.WriteLine(" --s3-bucket <bucket> S3 bucket");
|
||||
Console.WriteLine(" --s3-access-key <key> S3 access key");
|
||||
Console.WriteLine(" --s3-secret-key <key> S3 secret key");
|
||||
Console.WriteLine(" --s3-public-base-url <url> Public URL prefix for uploaded keys");
|
||||
Console.WriteLine(" [--s3-public-base-key-prefix <prefix>] Key prefix already represented by public URL");
|
||||
Console.WriteLine(" [--s3-prefix <prefix>] Object key prefix (default: lanmountain/update/plonds)");
|
||||
Console.WriteLine(" [--directory-upload-concurrency <n>] Parallel file uploads for expanded directories (default: 4)");
|
||||
Console.WriteLine(" [--multipart-threshold-mb <n>] Use multipart upload for files at or above this size (default: 8)");
|
||||
Console.WriteLine(" [--multipart-part-size-mb <n>] Multipart upload part size in MiB (default: 8)");
|
||||
Console.WriteLine(" [--multipart-concurrency <n>] Parallel multipart part uploads (default: 4)");
|
||||
Console.WriteLine(" [--work-dir <dir>] Temporary publish work directory");
|
||||
}
|
||||
|
||||
private static int GetInt(IReadOnlyDictionary<string, string> options, string key, int defaultValue)
|
||||
{
|
||||
if (!options.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return int.TryParse(value, out var parsed) && parsed > 0
|
||||
? parsed
|
||||
: throw new InvalidOperationException($"Option --{key} must be a positive integer.");
|
||||
}
|
||||
|
||||
private static long GetLong(IReadOnlyDictionary<string, string> options, string key, long defaultValue)
|
||||
{
|
||||
if (!options.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return long.TryParse(value, out var parsed) && parsed > 0
|
||||
? parsed
|
||||
: throw new InvalidOperationException($"Option --{key} must be a positive integer.");
|
||||
}
|
||||
}
|
||||
|
||||
329
SECURITY_AUDIT_REPORT_2026-06-01.md
Normal file
329
SECURITY_AUDIT_REPORT_2026-06-01.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# LanMountainDesktop 安全审计报告
|
||||
|
||||
**审计日期**: 2026-06-01
|
||||
**审计范围**: LanMountainDesktop 主仓库
|
||||
**审计方法**: 静态代码分析 + 架构审查 + 威胁建模
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
本次安全审计系统性地检查了 LanMountainDesktop 代码库的高风险攻击面,包括认证与访问控制、注入向量、外部交互和敏感数据处理。
|
||||
|
||||
**审计结论**: **未发现中等或更高严重度的已确认漏洞。**
|
||||
|
||||
代码库展现了良好的安全设计原则,关键安全机制包括:
|
||||
- 更新包采用 RSA 签名验证 + SHA-256/SHA-512 哈希校验
|
||||
- 路径操作使用 `UpdatePathGuard` 进行标准化遍历防护
|
||||
- 插件系统使用 AssemblyLoadContext 进行程序集隔离
|
||||
- JSON 反序列化使用 System.Text.Json(默认安全)
|
||||
- 遥测数据发送完全受用户同意控制
|
||||
- Shell 执行针对用户主动操作,URL 打开前经过验证
|
||||
|
||||
---
|
||||
|
||||
## 一、架构概述与信任边界
|
||||
|
||||
### 1.1 系统组件
|
||||
|
||||
| 组件 | 角色 | 信任级别 |
|
||||
|------|------|----------|
|
||||
| `LanMountainDesktop.Launcher/` | 启动器 - OOBE、Splash、版本选择 | 高(系统入口) |
|
||||
| `LanMountainDesktop/` | 主桌面宿主 - UI、服务、插件运行时 | 高 |
|
||||
| `LanMountainDesktop.AirAppRuntime/` | AirApp 独立容器 | 中 |
|
||||
| 插件系统 | 用户安装的扩展代码 | 低(需沙箱) |
|
||||
|
||||
### 1.2 数据流边界
|
||||
|
||||
```
|
||||
用户输入 → 新闻组件(RSS) → 解析后显示
|
||||
用户安装插件 → SHA256验证 → AssemblyLoadContext隔离 → 加载执行
|
||||
更新检查 → RSA签名验证 → SHA256校验 → 应用
|
||||
遥测数据 → 用户同意检查 → PostHog SDK → 上报
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、详细审计结果
|
||||
|
||||
### 2.1 认证与访问控制
|
||||
|
||||
**审计范围**: OOBE 流程、隐私协议、会话管理、权限校验
|
||||
|
||||
| 项目 | 位置 | 风险评估 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| OOBE 状态持久化 | `LanMountainDesktop.Launcher/Oobe/OobeStateService.cs` | ✅ 安全 | 原子写入,JSON Schema 版本控制 |
|
||||
| 隐私协议管理 | `PrivacyAgreementService.cs` | ✅ 安全 | 用户同意机制完善 |
|
||||
| LaunchSource 验证 | `CommandContext.cs` | ✅ 安全 | 参数白名单验证 |
|
||||
| 提权控制 | `ElevatedPluginInstallService.cs` | ✅ 安全 | 仅用于更新安装,需用户确认 |
|
||||
|
||||
**分析结论**: 本应用为本地桌面应用,无传统用户认证机制。隐私设置和遥测同意机制完善,用户可完全控制数据收集。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 注入向量
|
||||
|
||||
#### 2.2.1 路径遍历防护
|
||||
|
||||
**验证代码** ([UpdatePathGuard.cs:L11-18](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdatePathGuard.cs#L11-L18)):
|
||||
```csharp
|
||||
public static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
{
|
||||
var fullTarget = Path.GetFullPath(targetPath);
|
||||
var fullRoot = Path.GetFullPath(rootPath);
|
||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
||||
}
|
||||
}
|
||||
```
|
||||
✅ 使用 `OrdinalIgnoreCase` 防止大小写绕过,使用 `GetFullPath` 规范化路径。
|
||||
|
||||
#### 2.2.2 插件包文件名清理
|
||||
|
||||
**验证代码** ([PluginLoader.cs:L715-726](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginLoader.cs#L715-L726)):
|
||||
```csharp
|
||||
private static string SanitizeDirectoryName(string value)
|
||||
{
|
||||
var invalidCharacters = Path.GetInvalidFileNameChars();
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var ch in value)
|
||||
{
|
||||
builder.Append(invalidCharacters.Contains(ch) ? '_' : ch);
|
||||
}
|
||||
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
||||
}
|
||||
```
|
||||
✅ 插件目录名经过清理,避免路径注入。
|
||||
|
||||
#### 2.2.3 Shell 执行上下文
|
||||
|
||||
检查了 40+ 处 `Process.Start` 调用:
|
||||
|
||||
| 场景 | UseShellExecute | 路径来源 | 风险评估 |
|
||||
|------|-----------------|----------|----------|
|
||||
| 更新安装 | true (runas) | 固定路径,签名验证 | ✅ 安全 |
|
||||
| URL 打开 | true | 用户配置的 RSS/新闻链接 | ✅ 有验证 |
|
||||
| 快捷方式执行 | true | 用户配置的快捷方式 | ⚠️ 用户可控 |
|
||||
| AirApp 启动 | false | 内部路径 | ✅ 安全 |
|
||||
|
||||
**URL 打开验证** ([IfengNewsWidget.axaml.cs:L534-554](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs#L534-L554)):
|
||||
```csharp
|
||||
private static string? NormalizeHttpUrl(string? rawUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return uri.ToString();
|
||||
}
|
||||
```
|
||||
✅ URL 打开前验证协议必须为 http/https。
|
||||
|
||||
#### 2.2.4 JSON 反序列化
|
||||
|
||||
代码库广泛使用 `System.Text.Json` 进行反序列化:
|
||||
```csharp
|
||||
JsonSerializer.Deserialize<List<string>>(json); // PluginRuntimeService.cs:992
|
||||
JsonSerializer.Deserialize(text, AppJsonContext.Default.Options); // 多个位置
|
||||
```
|
||||
|
||||
✅ System.Text.Json 默认禁用类型元数据,可防止反序列化攻击。
|
||||
|
||||
**审计结论**: 注入向量风险评估为 **低**。路径操作有标准化防护,Shell 执行主要针对用户主动操作且 URL 有验证。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 外部交互
|
||||
|
||||
#### 2.3.1 更新系统安全机制
|
||||
|
||||
**RSA 签名验证** ([UpdateSignatureVerifier.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs)):
|
||||
```csharp
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath));
|
||||
var isValid = rsa.VerifyData(
|
||||
payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
```
|
||||
✅ 使用 PKCS#1 签名验证更新清单。
|
||||
|
||||
**文件哈希验证**:
|
||||
- 下载文件经过 SHA-256 校验
|
||||
- 插件包经过 SHA-256 + 大小双重校验
|
||||
- 支持 SHA-512 增强校验
|
||||
|
||||
#### 2.3.2 插件市场安全
|
||||
|
||||
**插件包完整性验证** ([PluginMarketInstallService.cs:L248-282](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketInstallService.cs#L248-L282)):
|
||||
```csharp
|
||||
// 大小校验
|
||||
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
|
||||
return verification failed;
|
||||
// SHA-256 校验
|
||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
return verification failed;
|
||||
```
|
||||
✅ 下载的插件包经过大小和哈希双重校验。
|
||||
|
||||
#### 2.3.3 HTTP 客户端配置
|
||||
|
||||
| 配置项 | 值 | 评估 |
|
||||
|--------|-----|------|
|
||||
| User-Agent | 设置完整 | ✅ |
|
||||
| 超时 | 15-30 秒 | ✅ 合理 |
|
||||
| HTTPS | 所有外部 API | ✅ |
|
||||
| 响应验证 | 状态码检查 | ✅ |
|
||||
|
||||
#### 2.3.4 外部 RSS/新闻数据
|
||||
|
||||
新闻组件从以下来源获取数据:
|
||||
- `imjuya.github.io/juya-ai-daily/rss.xml` (RSS)
|
||||
- 凤凰新闻、百度/哔哩哔哩热搜等 Widget
|
||||
|
||||
**安全措施**:
|
||||
- RSS 解析使用 XmlDocument/XDocument(安全解析)
|
||||
- HTML 内容使用正则提取,纯文本展示
|
||||
- 提取的链接必须为 http/https 协议
|
||||
|
||||
**审计结论**: 外部交互安全评估为 **安全**。所有更新和插件下载都有完整性验证。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 敏感数据处理
|
||||
|
||||
#### 2.4.1 API 密钥分析
|
||||
|
||||
| 服务 | 位置 | 评估 |
|
||||
|------|------|------|
|
||||
| Xiaomi Weather API | `XiaomiWeatherService.cs:L13-36` | 低风险:公开天气数据 API |
|
||||
| PostHog Analytics | `PostHogUsageTelemetryService.cs:L14` | 低风险:分析 SDK 公钥 |
|
||||
|
||||
**XiaomiWeatherService** ([XiaomiWeatherService.cs:L13-36](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L13-L36)):
|
||||
```csharp
|
||||
public sealed record XiaomiWeatherApiOptions
|
||||
{
|
||||
public string AppKey { get; init; } = "weather20151024";
|
||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
||||
}
|
||||
```
|
||||
⚠️ **说明**: 这些是天气数据 API 的公开凭证,用于获取公开天气数据,无用户敏感信息泄露风险。
|
||||
|
||||
#### 2.4.2 遥测服务
|
||||
|
||||
**遥测同意机制** ([PostHogUsageTelemetryService.cs:L71-100](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs#L71-L100)):
|
||||
```csharp
|
||||
public void RefreshEnabledState(bool forceSessionStart = false)
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var enabled = snapshot.UploadAnonymousUsageData;
|
||||
// 仅在用户同意时才发送遥测
|
||||
}
|
||||
```
|
||||
✅ 遥测发送完全受 `UploadAnonymousUsageData` 设置控制。
|
||||
|
||||
**遥测收集的数据**:
|
||||
- 安装 ID、应用版本、操作系统信息
|
||||
- 桌面组件交互事件
|
||||
- 设置页面导航事件
|
||||
|
||||
❌ **不包括**: 用户文件内容、个人文档、密码、API 密钥等敏感信息。
|
||||
|
||||
#### 2.4.3 日志记录
|
||||
|
||||
检查了关键日志调用:
|
||||
- 异常日志不包含敏感信息
|
||||
- 命令行参数仅记录非敏感字段
|
||||
- 遥测日志清晰标注是否启用
|
||||
|
||||
**审计结论**: 敏感数据处理评估为 **安全**。遥测受用户同意控制,无敏感信息日志记录。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 架构安全评估
|
||||
|
||||
#### 2.5.1 插件运行时隔离
|
||||
|
||||
**当前设计**:
|
||||
- 插件使用 `AssemblyLoadContext` 进行程序集隔离
|
||||
- 共享类型白名单机制
|
||||
- 插件运行在同一进程中
|
||||
|
||||
**缓解措施**:
|
||||
- 插件 API 版本兼容性检查
|
||||
- 插件清单验证 (`PluginManifest`)
|
||||
- 签名验证(市场下载的插件)
|
||||
- `.deps.json` 依赖验证
|
||||
|
||||
**风险说明**: 当前插件运行时属于进程内加载,这是已知的架构权衡。代码库已在 `.trae/specs/plugin-process-isolation/` 规划未来版本采用进程隔离方案。
|
||||
|
||||
#### 2.5.2 IPC 通信安全
|
||||
|
||||
外部 IPC 使用 `dotnetCampus.Ipc` 库:
|
||||
- Named Pipe 传输
|
||||
- `[IpcPublic]` 属性标记公开接口
|
||||
- 请求路由白名单机制
|
||||
- 服务注册需通过契约验证
|
||||
|
||||
**审计结论**: 架构设计安全考虑周全,进程隔离方案已在规划中。
|
||||
|
||||
---
|
||||
|
||||
## 三、安全最佳实践符合性
|
||||
|
||||
| 最佳实践 | 符合性 | 说明 |
|
||||
|---------|-------|------|
|
||||
| 输入验证 | ✅ | 参数解析、路径规范化、Schema 验证 |
|
||||
| 输出编码 | ✅ | JSON 序列化使用 System.Text.Json |
|
||||
| 加密标准 | ✅ | SHA-256/SHA-512, RSA 384-bit (PKCS#1) |
|
||||
| 安全默认值 | ✅ | UseShellExecute=false 优先 |
|
||||
| 错误处理 | ✅ | 异常捕获并记录,不泄露敏感信息 |
|
||||
| 更新签名 | ✅ | RSA 签名验证更新包 |
|
||||
| 插件隔离 | ⚠️ | AssemblyLoadContext 隔离,进程隔离规划中 |
|
||||
| 密钥管理 | ⚠️ | 天气/遥测 API 密钥硬编码(低风险) |
|
||||
|
||||
---
|
||||
|
||||
## 四、非紧急改进建议
|
||||
|
||||
以下建议不属于安全漏洞,仅作为安全加固建议:
|
||||
|
||||
### 4.1 API 密钥管理
|
||||
- 将天气 API 密钥移至配置系统
|
||||
- 考虑使用服务端代理访问天气 API
|
||||
- API 密钥轮换机制
|
||||
|
||||
### 4.2 插件进程隔离
|
||||
- 加速推进 `plugin-process-isolation` 规划
|
||||
- 评估 `dotnetCampus.Ipc` 进程间通信方案
|
||||
|
||||
### 4.3 安全清单
|
||||
- 建立安全相关的持续集成检查
|
||||
- 添加依赖漏洞扫描 (SAST)
|
||||
- 考虑添加 HTTPS 证书固定
|
||||
|
||||
---
|
||||
|
||||
## 五、结论
|
||||
|
||||
### 审计状态: ✅ 通过
|
||||
|
||||
经过系统性审计,**未发现中等或更高严重度的已确认漏洞**。
|
||||
|
||||
### 代码质量评价
|
||||
|
||||
代码库展现了良好的安全意识:
|
||||
|
||||
1. **关键操作多层防护**: 更新安装、插件加载都有完整性校验
|
||||
2. **路径操作标准化**: 使用 `UpdatePathGuard` 防止路径遍历
|
||||
3. **外部数据验证完善**: 插件包 SHA-256 校验、RSA 签名验证
|
||||
4. **用户隐私尊重**: 遥测完全受用户同意控制
|
||||
5. **Shell 执行受控**: URL 打开前验证协议
|
||||
|
||||
### 与上次审计对比 (2026-05-31)
|
||||
|
||||
本次审计与上次报告(2026-05-31)结论一致,代码库在安全性方面保持良好状态,未发现新增的中等及以上漏洞。
|
||||
|
||||
---
|
||||
|
||||
*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。*
|
||||
92
analyze_commits.ps1
Normal file
92
analyze_commits.ps1
Normal file
@@ -0,0 +1,92 @@
|
||||
# 分析当天的 Git 提交并生成 Markdown 报告
|
||||
|
||||
$todayStart = [DateTime]::Today
|
||||
$todayEnd = [DateTime]::Now
|
||||
$outputDir = "docs\auto_commit_md"
|
||||
|
||||
# 创建输出目录(如果不存在)
|
||||
if (-not (Test-Path $outputDir)) {
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
Write-Host "创建目录: $outputDir"
|
||||
}
|
||||
|
||||
# 获取当天的所有提交
|
||||
Write-Host "正在获取 $todayStart 到 $todayEnd 之间的提交..."
|
||||
$commits = git log --since="$($todayStart.ToString("yyyy-MM-dd HH:mm:ss"))" --until="$($todayEnd.ToString("yyyy-MM-dd HH:mm:ss"))" --pretty=format:"%H|%an|%ai|%s"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($commits)) {
|
||||
Write-Host "当天没有新的提交。"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "找到 $($commits.Split([Environment]::NewLine).Count) 个提交"
|
||||
|
||||
# 处理每个提交
|
||||
$commitLines = $commits -split [Environment]::NewLine
|
||||
foreach ($line in $commitLines) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
|
||||
$parts = $line -split '\|', 4
|
||||
$hash = $parts[0]
|
||||
$author = $parts[1]
|
||||
$date = $parts[2]
|
||||
$message = $parts[3]
|
||||
|
||||
$shortHash = $hash.Substring(0, 7)
|
||||
$dateStr = [DateTime]::Parse($date).ToString("yyyyMMdd")
|
||||
$outputFile = Join-Path $outputDir "${dateStr}_${shortHash}.md"
|
||||
|
||||
Write-Host "处理提交: $shortHash - $message"
|
||||
|
||||
# 获取详细的 diff
|
||||
$diff = git show --stat --stat-width=120 --stat-name-width=80 $hash
|
||||
$fullDiff = git show $hash
|
||||
|
||||
# 构建 Markdown 内容
|
||||
$markdown = @"
|
||||
# Git 提交分析报告
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **提交哈希** | $hash |
|
||||
| **短哈希** | $shortHash |
|
||||
| **作者** | $author |
|
||||
| **提交时间** | $date |
|
||||
|
||||
## 提交信息
|
||||
|
||||
$message
|
||||
|
||||
## 变更统计
|
||||
|
||||
``````
|
||||
$diff
|
||||
``````
|
||||
|
||||
## 详细变更
|
||||
|
||||
``````diff
|
||||
$fullDiff
|
||||
``````
|
||||
|
||||
## 代码审查要点
|
||||
|
||||
> 本部分由系统自动生成,需要人工审查确认。
|
||||
|
||||
- 请检查代码变更是否符合项目规范
|
||||
- 请检查是否有潜在的 bug 或安全问题
|
||||
- 请检查测试是否覆盖了新代码
|
||||
- 请检查文档是否需要更新
|
||||
|
||||
---
|
||||
*报告生成时间: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")*
|
||||
"@
|
||||
|
||||
# 保存文件
|
||||
$markdown | Out-File -FilePath $outputFile -Encoding UTF8
|
||||
Write-Host "已保存: $outputFile"
|
||||
}
|
||||
|
||||
Write-Host "`n完成!共生成 $($commitLines.Count) 份报告。"
|
||||
145
analyze_commits.py
Normal file
145
analyze_commits.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
分析当天的 Git 提交并生成 Markdown 报告
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
def run_git_command(cmd):
|
||||
"""运行 git 命令并返回输出"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace'
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
def main():
|
||||
# 设置日期范围
|
||||
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||
today_end = datetime.now()
|
||||
|
||||
output_dir = "docs/auto_commit_md"
|
||||
|
||||
# 创建输出目录
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
print(f"创建目录: {output_dir}")
|
||||
|
||||
# 获取当天的所有提交
|
||||
since_str = today_start.strftime("%Y-%m-%d %H:%M:%S")
|
||||
until_str = today_end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
print(f"正在获取 {since_str} 到 {until_str} 之间的提交...")
|
||||
|
||||
cmd = [
|
||||
"git", "log",
|
||||
f"--since={since_str}",
|
||||
f"--until={until_str}",
|
||||
"--pretty=format:%H|%an|%ai|%s"
|
||||
]
|
||||
|
||||
code, stdout, stderr = run_git_command(cmd)
|
||||
|
||||
if code != 0:
|
||||
print(f"错误: 获取提交失败: {stderr}")
|
||||
return
|
||||
|
||||
if not stdout.strip():
|
||||
print("当天没有新的提交。")
|
||||
return
|
||||
|
||||
commits = stdout.strip().split('\n')
|
||||
print(f"找到 {len(commits)} 个提交")
|
||||
|
||||
for line in commits:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split('|', 3)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
|
||||
hash_full = parts[0]
|
||||
author = parts[1]
|
||||
commit_date = parts[2]
|
||||
message = parts[3]
|
||||
|
||||
short_hash = hash_full[:7]
|
||||
date_obj = datetime.fromisoformat(commit_date.replace('Z', '+00:00'))
|
||||
date_str = date_obj.strftime("%Y%m%d")
|
||||
output_file = os.path.join(output_dir, f"{date_str}_{short_hash}.md")
|
||||
|
||||
print(f"处理提交: {short_hash} - {message}")
|
||||
|
||||
# 获取统计信息
|
||||
cmd_stat = ["git", "show", "--stat", "--stat-width=120", "--stat-name-width=80", hash_full]
|
||||
_, stat_out, _ = run_git_command(cmd_stat)
|
||||
|
||||
# 获取完整 diff
|
||||
cmd_diff = ["git", "show", hash_full]
|
||||
_, diff_out, _ = run_git_command(cmd_diff)
|
||||
|
||||
# 构建 Markdown 内容
|
||||
markdown = f"""# Git 提交分析报告
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **提交哈希** | {hash_full} |
|
||||
| **短哈希** | {short_hash} |
|
||||
| **作者** | {author} |
|
||||
| **提交时间** | {commit_date} |
|
||||
|
||||
## 提交信息
|
||||
|
||||
{message}
|
||||
|
||||
## 变更统计
|
||||
|
||||
```
|
||||
{stat_out}
|
||||
```
|
||||
|
||||
## 详细变更
|
||||
|
||||
```diff
|
||||
{diff_out}
|
||||
```
|
||||
|
||||
## 代码审查要点
|
||||
|
||||
> 本部分由系统自动生成,需要人工审查确认。
|
||||
|
||||
- 请检查代码变更是否符合项目规范
|
||||
- 请检查是否有潜在的 bug 或安全问题
|
||||
- 请检查测试是否覆盖了新代码
|
||||
- 请检查文档是否需要更新
|
||||
|
||||
---
|
||||
*报告生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}*
|
||||
"""
|
||||
|
||||
# 保存文件
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(markdown)
|
||||
|
||||
print(f"已保存: {output_file}")
|
||||
|
||||
print(f"\n完成!共生成 {len(commits)} 份报告。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,156 +1,46 @@
|
||||
# 提交历史分析文档
|
||||
# Git 提交分析工具使用说明
|
||||
|
||||
本目录包含 LanMountainDesktop 项目的所有 Git 提交分析报告。
|
||||
## 概述
|
||||
|
||||
## 文档统计
|
||||
本工具用于分析当天(2026-06-01)的 Git 提交,并为每个提交生成结构化的 Markdown 分析报告。
|
||||
|
||||
| 统计项 | 数量 |
|
||||
|--------|------|
|
||||
| **总文档数** | **120 个** |
|
||||
| 版本发布 (Release) | 11 个 |
|
||||
| 功能新增 (Feature) | 45 个 |
|
||||
| Bug 修复 (Bug Fix) | 32 个 |
|
||||
| 文档更新 (Documentation) | 8 个 |
|
||||
| CI/CD 相关 | 18 个 |
|
||||
| 代码重构 (Refactoring) | 6 个 |
|
||||
## 文件说明
|
||||
|
||||
## 文档命名规则
|
||||
- `run_analysis.py` - 主分析脚本(推荐使用)
|
||||
- `analyze_commits.py` - Python 版本分析脚本
|
||||
- `analyze_commits.ps1` - PowerShell 版本分析脚本
|
||||
|
||||
每个文档的命名格式为:`YYYYMMDD_<commit_short_hash>.md`
|
||||
## 使用方法
|
||||
|
||||
- `YYYYMMDD` - 提交日期
|
||||
- `<commit_short_hash>` - 提交哈希的前7位
|
||||
|
||||
## 时间分布
|
||||
|
||||
| 月份 | 提交数量 |
|
||||
|------|----------|
|
||||
| 2025年4月 | 11 个 |
|
||||
| 2025年5月 | 100 个 |
|
||||
| 2025年6月 | 9 个 |
|
||||
|
||||
## 重要提交概览
|
||||
|
||||
### 版本发布
|
||||
- [20250427_bd2313f](20250427_bd2313f.md) - 0.7.9.1
|
||||
- [20250428_f84111e](20250428_f84111e.md) - 0.7.9.2
|
||||
- [20250428_148e4c8](20250428_148e4c8.md) - 0.8.0
|
||||
- [20250428_5804627](20250428_5804627.md) - 0.8.0.1
|
||||
- [20250428_2dc729c](20250428_2dc729c.md) - 0.8.0.2
|
||||
- [20250429_9045624](20250429_9045624.md) - 0.8.0.3
|
||||
- [20250429_3b810fd](20250429_3b810fd.md) - 0.8.0.4
|
||||
- [20250429_f50cfed](20250429_f50cfed.md) - 0.8.0.5
|
||||
|
||||
### 重要功能
|
||||
- [20250501_964cef2](20250501_964cef2.md) - 通知系统,自习系统
|
||||
- [20250501_88bd92e](20250501_88bd92e.md) - Hub组件支持双击打开图片,三指翻页退出
|
||||
- [20250502_44b87ba](20250502_44b87ba.md) - 桌面组件
|
||||
- [20250502_1c3cc76](20250502_1c3cc76.md) - 状态栏文字组件,支持位置放置
|
||||
- [20250503_0662565](20250503_0662565.md) - 文件管理组件跨平台支持
|
||||
- [20250505_e1d5a0c](20250505_e1d5a0c.md) - 电源菜单
|
||||
- [20250505_e69bbf8](20250505_e69bbf8.md) - 快捷方式组件
|
||||
- [20250506_8c94253](20250506_8c94253.md) - 快捷方式组件透明问题修复
|
||||
- [20250507_11130cf](20250507_11130cf.md) - 更新界面多标题修复
|
||||
- [20250509_cb96180](20250509_cb96180.md) - 白板笔色自适应主题
|
||||
- [20250510_4a89c23](20250510_4a89c23.md) - 便签组件
|
||||
- [20250511_76d13ac](20250511_76d13ac.md) - 开发者调试工具
|
||||
- [20250514_c2cc62b](20250514_c2cc62b.md) - 淡入淡出动画
|
||||
- [20250514_03e32ee](20250514_03e32ee.md) - 网速显示组件
|
||||
- [20250516_81ee19f](20250516_81ee19f.md) - AOT启动器
|
||||
- [20250519_02547ee](20250519_02547ee.md) - 引入Velopack更新系统
|
||||
- [20250520_a31ae3c](20250520_a31ae3c.md) - Penguin Logistics Online Network Distribution System
|
||||
- [20250521_703ed7b](20250521_703ed7b.md) - 重构启动器启动、日志和主机解析
|
||||
- [20250521_9224c9a](20250521_9224c9a.md) - 强化OOBE、启动源和权限流程
|
||||
- [20250521_aa7c118](20250521_aa7c118.md) - 添加外部公共IPC主机/客户端和插件SDK
|
||||
- [20250522_e20462a](20250522_e20462a.md) - 设置窗口独立化和任务栏感知
|
||||
- [20250523_8b8c7d1](20250523_8b8c7d1.md) - 简化启动画面为淡入淡出
|
||||
- [20250524_5b4b9f3](20250524_5b4b9f3.md) - OOBE重新设计、主题和数据位置支持
|
||||
- [20250525_d310fc5](20250525_d310fc5.md) - Avalonia 12升级
|
||||
- [20250528_9fb4137](20250528_9fb4137.md) - 迁移代码库到Avalonia 12 API
|
||||
- [20250528_93d6d93](20250528_93d6d93.md) - 迁移到Avalonia 12和Plugin SDK v5
|
||||
- [20250529_eb066b5](20250529_eb066b5.md) - 引入渲染模式和静态组件预览
|
||||
- [20250530_0348324](20250530_0348324.md) - 添加LauncherPathResolver和重构数据路径
|
||||
- [20250601_6a30bc6](20250601_6a30bc6.md) - 重构设置窗口UI和主题
|
||||
- [20250601_49bbae2](20250601_49bbae2.md) - 使用Fluent Shell和搜索重新设计设置窗口
|
||||
- [20250603_60e7f31](20250603_60e7f31.md) - 添加OOBE启动演示和设置合并
|
||||
- [20250605_68ca532](20250605_68ca532.md) - 将白板持久化移动到文件存储
|
||||
- [20250605_aa7e15d](20250605_aa7e15d.md) - 添加CODE_WIKI和更新本地化
|
||||
- [20250605_84caca0](20250605_84caca0.md) - 数据设置页面和存储扫描器
|
||||
|
||||
### 样式统一
|
||||
- [20250428_7a26848](20250428_7a26848.md) - CI.圆角
|
||||
- [20250505_8583465](20250505_8583465.md) - 圆角统一
|
||||
|
||||
### Bug 修复
|
||||
- [20250430_2272d35](20250430_2272d35.md) - 回退 0.8.0.41
|
||||
- [20250501_ff01471](20250501_ff01471.md) - 修复智教 Hub 组件
|
||||
- [20250502_021c7ff](20250502_021c7ff.md) - 修复智教Hub组件
|
||||
- [20250502_00339f0](20250502_00339f0.md) - 修复Rinshub
|
||||
- [20250506_66ae0b0](20250506_66ae0b0.md) - 课表组件日间模式字体颜色修复
|
||||
- [20250508_cf4b8e2](20250508_cf4b8e2.md) - 央广网新闻组件第二行显示修复
|
||||
- [20250508_e8ba847](20250508_e8ba847.md) - 融合桌面设置窗口修复
|
||||
- [20250512_b933f3b](20250512_b933f3b.md) - 开发者调试工具设置持久化修复
|
||||
- [20250512_ce5acf5](20250512_ce5acf5.md) - 快捷方式组件透明问题修复
|
||||
- [20250515_e9ff590](20250515_e9ff590.md) - 可爱的我一直在修CI
|
||||
- [20250516_6c526ff](20250516_6c526ff.md) - 修CI,Linux问题
|
||||
- [20250518_9cf3a15](20250518_9cf3a15.md) - 修复启动器无法正常启动的问题
|
||||
- [20250518_4f9feaf](20250518_4f9feaf.md) - 继续修CI
|
||||
- [20250519_8e39ea8](20250519_8e39ea8.md) - GitHub Action工作流修复
|
||||
- [20250519_6343164](20250519_6343164.md) - 修CI,修融合桌面,修启动器
|
||||
- [20250528_f8073c2](20250528_f8073c2.md) - 修复合并产生的问题
|
||||
|
||||
### CI/CD 相关
|
||||
- [20250515_59c4824](20250515_59c4824.md) - 启动器一定要能够启动
|
||||
- [20250516_53ff98f](20250516_53ff98f.md) - Update build.yml
|
||||
- [20250518_e8d2575](20250518_e8d2575.md) - 测试增量更新Velopack
|
||||
- [20250519_f6a6f97](20250519_f6a6f97.md) - 迁移发布管道到签名文件映射
|
||||
- [20250519_858612f](20250519_858612f.md) - 使可选S3上传步骤工作流解析安全
|
||||
- [20250519_833c693](20250519_833c693.md) - 使增量包生成对空差异和Linux路径健壮
|
||||
- [20250519_24b361b](20250519_24b361b.md) - 轮换启动器更新公钥
|
||||
- [20250519_cddebbc](20250519_cddebbc.md) - 恢复稳定的启动器更新公钥
|
||||
- [20250519_48ce93b](20250519_48ce93b.md) - 同步启动器公钥与更新签名密钥
|
||||
- [20250519_1e6b61d](20250519_1e6b61d.md) - 规范化PEM行尾
|
||||
- [20250519_c5ef418](20250519_c5ef418.md) - 轮换启动器公钥以匹配CI签名密钥
|
||||
- [20250519_62e7d96](20250519_62e7d96.md) - 通过SPKI而非PEM文本比较签名密钥
|
||||
- [20250519_fb21bcd](20250519_fb21bcd.md) - 重构更新后端到主机管理的PDC管道
|
||||
- [20250520_81e0081](20250520_81e0081.md) - 修复发布工作流环境密钥冲突
|
||||
- [20250520_8447910](20250520_8447910.md) - 放宽发布PDC预检查仅需要S3
|
||||
- [20250520_8c58b1c](20250520_8c58b1c.md) - 为发布添加本地PDC模拟回退
|
||||
- [20250520_e82c5d4](20250520_e82c5d4.md) - 为PDCC安装程序步骤设置GH_TOKEN
|
||||
- [20250521_001a42a](20250521_001a42a.md) - 修复Windows安装程序脚本路径
|
||||
- [20250521_631dc77](20250521_631dc77.md) - 规范化发布工件
|
||||
- [20250521_8a75bc8](20250521_8a75bc8.md) - 围绕PLONDS和DDSS重建发布管道
|
||||
|
||||
### 文档更新
|
||||
- [20250505_d30af21](20250505_d30af21.md) - 加入CHANGELOG
|
||||
- [20250510_d62226f](20250510_d62226f.md) - 更新CHANGELOG
|
||||
- [20250512_1b22e9d](20250512_1b22e9d.md) - 新增插件开发文档
|
||||
|
||||
## 查看完整提交历史
|
||||
|
||||
如需查看完整的提交历史,请使用以下命令:
|
||||
### 方法一:使用 Python 脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 查看所有提交
|
||||
git log --oneline
|
||||
|
||||
# 查看详细提交信息
|
||||
git log --pretty=format:"%H|%an|%ad|%s" --date=format:"%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# 查看特定提交的详细变更
|
||||
git show <commit_hash>
|
||||
python run_analysis.py
|
||||
```
|
||||
|
||||
## 文档内容结构
|
||||
### 方法二:使用 PowerShell 脚本
|
||||
|
||||
每个 Markdown 文件包含以下部分:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File analyze_commits.ps1
|
||||
```
|
||||
|
||||
1. **基本信息表** - 提交哈希、作者、时间、父提交等
|
||||
2. **提交信息分析** - 对提交内容的解读
|
||||
3. **变更概览** - 查看详细变更的命令
|
||||
4. **提交类型** - 分类标记(版本发布、功能新增、Bug修复等)
|
||||
5. **相关文档/链接** - 与提交相关的项目文档
|
||||
## 输出格式
|
||||
|
||||
## 更新时间
|
||||
每个提交会生成一个 Markdown 文件,命名格式为:`YYYYMMDD_<commit_short_hash>.md`
|
||||
|
||||
本文档集生成于:2026-05-07
|
||||
报告包含以下内容:
|
||||
1. **基本信息** - 提交哈希、作者、时间等
|
||||
2. **提交信息** - 提交说明
|
||||
3. **变更统计** - 文件变更统计
|
||||
4. **详细变更** - 完整的 Git diff
|
||||
5. **代码审查要点** - 人工审查提示
|
||||
|
||||
## 输出目录
|
||||
|
||||
所有报告保存在:`docs/auto_commit_md/`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保已安装 Git 并配置好环境
|
||||
- 确保当前目录是 Git 仓库
|
||||
- 脚本仅分析当天(2026-06-01)的提交
|
||||
|
||||
177
run_analysis.py
Normal file
177
run_analysis.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
运行 Git 提交分析
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
def run_command(cmd, shell=False):
|
||||
"""运行命令并返回结果"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
shell=shell,
|
||||
timeout=30
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, "", "命令超时"
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Git 提交分析工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 创建输出目录
|
||||
output_dir = "docs/auto_commit_md"
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
print(f"输出目录: {os.path.abspath(output_dir)}")
|
||||
except Exception as e:
|
||||
print(f"创建目录失败: {e}")
|
||||
return 1
|
||||
|
||||
# 检查是否是 Git 仓库
|
||||
code, _, stderr = run_command(["git", "rev-parse", "--is-inside-work-tree"])
|
||||
if code != 0:
|
||||
print(f"错误: 不是 Git 仓库: {stderr}")
|
||||
return 1
|
||||
|
||||
# 设置日期范围
|
||||
today = date.today()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.now()
|
||||
|
||||
since_str = today_start.strftime("%Y-%m-%d %H:%M:%S")
|
||||
until_str = today_end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
print(f"\n分析日期范围: {since_str} 到 {until_str}")
|
||||
|
||||
# 获取提交列表
|
||||
print("\n正在获取提交列表...")
|
||||
cmd = [
|
||||
"git", "log",
|
||||
f"--since={since_str}",
|
||||
f"--until={until_str}",
|
||||
"--pretty=format:%H|%an|%ai|%s",
|
||||
"--no-merges"
|
||||
]
|
||||
|
||||
code, stdout, stderr = run_command(cmd)
|
||||
if code != 0:
|
||||
print(f"获取提交失败: {stderr}")
|
||||
return 1
|
||||
|
||||
if not stdout.strip():
|
||||
print("当天没有新的提交。")
|
||||
return 0
|
||||
|
||||
commits = [line.strip() for line in stdout.strip().split('\n') if line.strip()]
|
||||
print(f"找到 {len(commits)} 个提交\n")
|
||||
|
||||
# 处理每个提交
|
||||
for i, line in enumerate(commits, 1):
|
||||
parts = line.split('|', 3)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
|
||||
hash_full = parts[0]
|
||||
author = parts[1]
|
||||
commit_date = parts[2]
|
||||
message = parts[3]
|
||||
|
||||
short_hash = hash_full[:7]
|
||||
|
||||
try:
|
||||
date_obj = datetime.fromisoformat(commit_date.replace('Z', '+00:00'))
|
||||
date_str = date_obj.strftime("%Y%m%d")
|
||||
except:
|
||||
date_str = today.strftime("%Y%m%d")
|
||||
|
||||
output_file = os.path.join(output_dir, f"{date_str}_{short_hash}.md")
|
||||
|
||||
print(f"[{i}/{len(commits)}] 处理: {short_hash}")
|
||||
print(f" 作者: {author}")
|
||||
print(f" 信息: {message[:60]}{'...' if len(message) > 60 else ''}")
|
||||
|
||||
# 获取统计信息
|
||||
cmd_stat = ["git", "show", "--stat", "--stat-width=120", "--stat-name-width=80", hash_full]
|
||||
_, stat_out, _ = run_command(cmd_stat)
|
||||
|
||||
# 获取完整 diff
|
||||
cmd_diff = ["git", "show", hash_full]
|
||||
_, diff_out, _ = run_command(cmd_diff)
|
||||
|
||||
# 构建 Markdown
|
||||
markdown = f"""# Git 提交分析报告
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **提交哈希** | {hash_full} |
|
||||
| **短哈希** | {short_hash} |
|
||||
| **作者** | {author} |
|
||||
| **提交时间** | {commit_date} |
|
||||
|
||||
## 提交信息
|
||||
|
||||
{message}
|
||||
|
||||
## 变更统计
|
||||
|
||||
```
|
||||
{stat_out}
|
||||
```
|
||||
|
||||
## 详细变更
|
||||
|
||||
```diff
|
||||
{diff_out}
|
||||
```
|
||||
|
||||
## 代码审查要点
|
||||
|
||||
> 本部分由系统自动生成,需要人工审查确认。
|
||||
|
||||
- 请检查代码变更是否符合项目规范
|
||||
- 请检查是否有潜在的 bug 或安全问题
|
||||
- 请检查测试是否覆盖了新代码
|
||||
- 请检查文档是否需要更新
|
||||
|
||||
---
|
||||
*报告生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}*
|
||||
"""
|
||||
|
||||
# 保存文件
|
||||
try:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(markdown)
|
||||
print(f" 已保存: {os.path.basename(output_file)}")
|
||||
except Exception as e:
|
||||
print(f" 保存失败: {e}")
|
||||
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print(f"完成!共生成 {len(commits)} 份报告")
|
||||
print(f"报告位置: {os.path.abspath(output_dir)}")
|
||||
print("=" * 60)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user