Compare commits

...

8 Commits

Author SHA1 Message Date
lincube
8403b89a15 fiz.4×2日历组件日期显示修复 2026-06-02 14:28:33 +08:00
lincube
0ea98c08bf feat.PLONDS客户端补全 2026-06-02 13:16:13 +08:00
lincube
54d97e312d fix.plonds-s3-multipart-upload 2026-06-02 10:09:06 +08:00
lincube
04b95020bd fix.plonds-s3-resumable-publish 2026-06-02 09:27:08 +08:00
lincube
cf08269e15 fix.plonds-s3-upload-timeout 2026-06-02 08:51:53 +08:00
lincube
03e4442e74 feat.PLONDS客户端 2026-06-01 19:48:51 +08:00
lincube
0c8830133a feat.Publisher完整包上传 2026-06-01 17:28:26 +08:00
lincube
131043fe37 changed.修改了PLONDS上传逻辑 2026-06-01 16:53:23 +08:00
61 changed files with 6126 additions and 1817 deletions

View File

@@ -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"

View File

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

View File

@@ -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
}

View 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。

View File

@@ -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 命令的清理(后续处理)

File diff suppressed because it is too large Load Diff

View 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)
});
}
}
}

View File

@@ -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();

View File

@@ -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()

View File

@@ -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);
}

View 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);
}

View 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);

View 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");

View File

@@ -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();
}
}

View 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}");
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsInstallResult(
bool Success,
string? ErrorMessage,
string? ErrorCode = null);

View 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);
}
}

View File

@@ -0,0 +1,5 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsManifestCandidate(
PlondsSourceDescriptor Source,
PlondsClientManifest Manifest);

View 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
};
}

View 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);
}
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.Services.Plonds;
internal enum PlondsPackageMode
{
Delta,
Full
}

View 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);
}
}

View 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);
}

View 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);

View File

@@ -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
{
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsSourceDescriptor(
string Id,
string Kind,
string ManifestUrl,
int Priority = 0);

View 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);
}
}

View 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);
}

View 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

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

View File

@@ -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!);
}
}

View File

@@ -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}";
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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))
{

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 =>

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsS3ObjectInfo(
string Key,
long? ContentLength,
string? ETag);

View File

@@ -0,0 +1,6 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsS3ObjectUpload(
string SourcePath,
string Key,
string ContentType);

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record PlondsSourceDescriptor(
string Id,
string Kind,
string ManifestUrl,
int Priority = 0);

View File

@@ -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.");
}
}

View 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
View 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
View 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()

View File

@@ -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) - 修CILinux问题
- [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
View 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())