Compare commits

..

17 Commits

Author SHA1 Message Date
lincube
29bd47986c Merge branch 'main' of https://github.com/wwiinnddyy/LanMountainDesktop 2026-06-02 16:31:36 +08:00
lincube
b12c9bf11d fix.元素动画系统导致的调整组件闪现问题 2026-06-02 16:31:29 +08:00
lincube
dd73e02bce 更新 plonds-uploader.yml 2026-06-02 16:24:58 +08:00
lincube
ed66869c8d 更新 plonds-uploader.yml 2026-06-02 15:55:37 +08:00
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
lincube
a2ac302ee7 fix. 插件安装修复 2026-06-01 01:12:52 +08:00
lincube
c351a8e7f3 feat.airapp剥离启动器 2026-05-31 19:41:10 +08:00
lincube
21e970c5b6 fix.修复了窗口问题,以及多次显示圆角调节选项的问题。 2026-05-31 12:12:56 +08:00
lincube
17873f0f43 fix.修复设置页面 2026-05-30 17:15:16 +08:00
lincube
4051b5cd74 qchanged. 修改了Mac OS打包逻辑 2026-05-30 16:11:25 +08:00
173 changed files with 10015 additions and 3357 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: '10'
PLONDS_S3_MULTIPART_PART_SIZE_MB: '10'
PLONDS_S3_MULTIPART_CONCURRENCY: '4'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 360
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

@@ -185,6 +185,29 @@ jobs:
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
shell: pwsh
- name: Publish AirAppRuntime
run: |
$arch = "${{ matrix.arch }}"
$publishDir = "publish/airapp-runtime-win-$arch"
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-r win-$arch `
-p:SelfContained=false `
-p:PublishAot=false `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
shell: pwsh
- name: Publish AirAppHost
run: |
$arch = "${{ matrix.arch }}"
@@ -215,6 +238,7 @@ jobs:
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$launcherPublishDir = "publish/launcher-win-$arch"
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
$appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch"
@@ -226,10 +250,15 @@ jobs:
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
}
if (Test-Path $runtimePublishDir) {
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
}
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -Path $newStructure -Destination $publishDir -Force
shell: pwsh
@@ -253,6 +282,7 @@ jobs:
$requiredFiles = @(
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
(Join-Path $appDir "LanMountainDesktop.exe"),
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
)
@@ -330,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
@@ -344,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
}
@@ -462,12 +492,32 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish AirAppRuntime
run: |
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
-c Release \
-o ./publish/airapp-runtime-linux-x64 \
--self-contained false \
-r linux-x64 \
-p:SelfContained=false \
-p:PublishAot=false \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Restructure for Launcher
run: |
version="${{ needs.prepare.outputs.version }}"
publishDir="publish/linux-x64"
appDir="app-$version"
launcherDir="publish/launcher-linux-x64"
runtimeDir="publish/airapp-runtime-linux-x64"
mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir"
@@ -477,8 +527,13 @@ jobs:
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
fi
if [ -d "$runtimeDir" ]; then
cp -r "$runtimeDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
fi
touch "$publishDir/$appDir/.current"
rm -rf "$launcherDir"
rm -rf "$launcherDir" "$runtimeDir"
- name: Package as DEB
run: |
@@ -637,10 +692,10 @@ jobs:
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/macos-${{ matrix.arch }}-app \
--self-contained \
--self-contained:false \
-r osx-${{ matrix.arch }} \
-p:SelfContained=false \
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:SkipAirAppHostBuild=true \
@@ -651,6 +706,36 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish AirAppRuntime
run: |
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
-c Release \
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
--self-contained false \
-r osx-${{ matrix.arch }} \
-p:SelfContained=false \
-p:PublishAot=false \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Optimize and Guard macOS Payload
run: |
arch="${{ matrix.arch }}"
publishDir="publish/macos-${arch}-app"
pwsh ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 \
-PublishDir "$publishDir" \
-RuntimeIdentifier "osx-${arch}" \
-AssertClean
shell: bash
- name: Package Payload Zip
run: |
release_dir="$PWD/release-assets"
@@ -673,6 +758,7 @@ jobs:
app_name="LanMountainDesktop"
package_name="${app_name}-${version}-macos-${arch}"
launcherDir="publish/launcher-macos-$arch"
runtimeDir="publish/airapp-runtime-macos-$arch"
appSourceDir="publish/macos-$arch-app"
mkdir -p "${app_name}.app/Contents/MacOS"
@@ -685,6 +771,11 @@ jobs:
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
fi
if [ -d "$runtimeDir" ]; then
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
fi
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources"

View File

@@ -0,0 +1,9 @@
# Checklist
- [x] `LanMountainDesktop.AirAppRuntime` is included in `LanMountainDesktop.slnx`.
- [x] Launcher no longer hosts `IAirAppLifecycleService`.
- [x] Host fallback starts `LanMountainDesktop.AirAppRuntime`, not `LanMountainDesktop.Launcher air-app-broker`.
- [x] AirApp Runtime is explicitly non-AOT and framework-dependent.
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` passes.
- [x] Related AirApp Runtime tests pass.
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` passes.

View File

@@ -0,0 +1,21 @@
# AirApp Runtime Container
## Goal
Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`.
## Behavior
- Launcher remains the user-facing entry point and pre-starts AirApp Runtime during normal `launch`.
- AirApp Runtime exposes `IAirAppLifecycleService` and `IAirAppRuntimeControlService` on `LanMountainDesktop.AirAppRuntime.v1`.
- Desktop host requests Air APP operations through AirApp Runtime IPC.
- If the runtime pipe is unavailable, the desktop host starts `LanMountainDesktop.AirAppRuntime` directly and retries.
- AirApp Runtime keeps one AirAppHost process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key, with `world-clock` sharing `world-clock:clock-suite:global`.
- AirApp Runtime remains alive while Launcher, Host, requester, or any AirAppHost process is alive.
- AirApp Runtime exits after Launcher/Host/requester are gone and no Air APP windows remain.
## Out of Scope
- Moving Air APP windows into the runtime process.
- Third-party plugin-declared Air APP metadata.
- Persisting the Air APP instance table across OS reboot.

View File

@@ -0,0 +1,11 @@
# Tasks
- [x] Add shared AirApp Runtime IPC/control contracts.
- [x] Add shared AirApp Runtime path resolver and process starter.
- [x] Add `LanMountainDesktop.AirAppRuntime` as a framework-dependent JIT process.
- [x] Move Air APP lifecycle service out of Launcher.
- [x] Make Launcher pre-start AirApp Runtime and attach Host PID after launch.
- [x] Make Host fallback start AirApp Runtime instead of Launcher broker.
- [x] Remove Launcher `air-app-broker` command handling.
- [x] Update packaging scripts and release workflow to include AirApp Runtime.
- [x] Update unit tests and architecture/package assertions.

View File

@@ -1,5 +1,7 @@
# Checklist
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
- [x] `LanMountainDesktop.Launcher` builds in Debug.
- [x] `LanMountainDesktop` builds in Debug.

View File

@@ -1,5 +1,7 @@
# Launcher Managed Air APP Lifecycle
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
## Goal
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.

View File

@@ -1,5 +1,7 @@
# Tasks
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
- [x] Add shared Air APP lifecycle IPC contracts.
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
- [x] Make Launcher remain alive while desktop or Air APP processes exist.

View File

@@ -3,7 +3,7 @@
- [ ] New install shows OOBE once.
- [ ] Same-user reinstall does not show OOBE again.
- [ ] `postinstall` launch path is handled without misclassifying the user state.
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
- [ ] `plugin-install` does not auto-enter OOBE.
- [ ] Default plugin install does not request UAC.
- [ ] Logs include OOBE status, suppression reason, and launch source.
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).

View File

@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
- `launchSource` values are treated as:
- `normal`
- `postinstall`
- `apply-update`
- `plugin-install`
- `debug-preview`
- Automatic OOBE is allowed only for normal user-mode startup.
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
- Allowed elevation paths are limited to:
- the installer itself
- full installer update application

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

View File

@@ -8,7 +8,10 @@ Rebuild the settings window as an independent Fluent shell with a custom titleba
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
- Keep the titlebar and content area on one shared full-window background layer; the custom titlebar must remain transparent and must not paint a contrasting strip.
- Avoid a visible titlebar bottom divider that makes the titlebar read as a separate color band.
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
- Keep `FANavigationView` pane and content template backgrounds transparent in the settings shell so the navigation control does not reintroduce a second surface color.
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
- Add `auto` system material mode and make it the default.

View File

@@ -15,7 +15,7 @@ Make the Settings > Update page the single user-facing control surface for the h
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
- The page displays whether the current payload is an incremental update or reinstall/full installer.
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged.
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
## Acceptance

View File

@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
- The project has switched back to signed FileMap incremental assets as the primary update path.
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
## Migration Note

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
namespace LanMountainDesktop.Launcher.AirApp;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppHostLocator
{
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
private static string ExecutableName => OperatingSystem.IsWindows()
? WindowsExecutableName
: UnixExecutableName;
public string Resolve(string? packageRoot, string? hostPath = null)
{
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
{
foreach (var root in EnumerateRoots(packageRoot, hostPath))
{
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
yield return Path.Combine(root, "AirAppHost", ExecutableName);
yield return Path.Combine(root, "AirAppHost", DllName);
yield return Path.Combine(root, WindowsExecutableName);
yield return Path.Combine(root, ExecutableName);
yield return Path.Combine(root, DllName);
if (Directory.Exists(root))
{
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
{
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
yield return Path.Combine(deploymentDirectory, ExecutableName);
yield return Path.Combine(deploymentDirectory, DllName);
}
}
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
"Release",
#endif
"net10.0",
WindowsExecutableName);
ExecutableName);
yield return Path.Combine(
current.FullName,

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.AirApp;
namespace LanMountainDesktop.AirAppRuntime;
internal static class AirAppInstanceKey
{
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value)
? fallback
: value.Trim();
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
}

View File

@@ -2,15 +2,15 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.AirApp;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
{
private readonly object _gate = new();
private readonly IAirAppProcessStarter _processStarter;
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
{
_processStarter = processStarter;
}
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
ArgumentNullException.ThrowIfNull(request);
var appId = Normalize(request.AppId, "unknown");
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
Logger.Info(
AirAppRuntimeLogger.Info(
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
lock (_gate)
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
request.SourceComponentId,
request.SourcePlacementId);
_instances[instanceKey] = instance;
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
}
catch (Exception ex)
{
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
}
}
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
request.SourceComponentId,
request.SourcePlacementId);
_instances[instanceKey] = instance;
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
}
}
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
(processId <= 0 || instance.ProcessId == processId))
{
_instances.Remove(instanceKey);
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
}
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
foreach (var key in exitedKeys)
{
_instances.Remove(key);
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
}
}
@@ -237,7 +237,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
}
}
private static bool IsProcessAlive(int processId)
internal static bool IsProcessAlive(int processId)
{
if (processId <= 0)
{
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
private static string Normalize(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value)
? fallback
: value.Trim();
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private const int SW_SHOWNORMAL = 1;

View File

@@ -0,0 +1,29 @@
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeControlService : IAirAppRuntimeControlService
{
private readonly AirAppRuntimeLifetime _lifetime;
public AirAppRuntimeControlService(AirAppRuntimeLifetime lifetime)
{
_lifetime = lifetime;
}
public Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
{
_lifetime.AttachHost(hostProcessId);
var status = _lifetime.GetStatus();
return Task.FromResult(new AirAppRuntimeControlResult(
hostProcessId > 0,
hostProcessId > 0 ? "host_attached" : "invalid_host_pid",
hostProcessId > 0 ? "AirApp runtime host process attached." : "Host process id must be positive.",
status));
}
public Task<AirAppRuntimeStatus> GetStatusAsync()
{
return Task.FromResult(_lifetime.GetStatus());
}
}

View File

@@ -0,0 +1,29 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeIpcHost : IDisposable
{
private readonly PublicIpcHostService _host;
public AirAppRuntimeIpcHost(
AirAppLifecycleService lifecycleService,
AirAppRuntimeControlService controlService)
{
_host = new PublicIpcHostService(IpcConstants.AirAppRuntimePipeName);
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
_host.RegisterPublicService<IAirAppRuntimeControlService>(controlService);
}
public void Start()
{
_host.Start();
AirAppRuntimeLogger.Info($"Air APP runtime IPC started. Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
}
public void Dispose()
{
_host.Dispose();
}
}

View File

@@ -0,0 +1,77 @@
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeLifetime
{
private readonly object _gate = new();
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
private readonly AirAppLifecycleService _lifecycleService;
private readonly int _launcherProcessId;
private readonly int _requesterProcessId;
private int _hostProcessId;
private DateTimeOffset _updatedAtUtc;
public AirAppRuntimeLifetime(AirAppRuntimeOptions options, AirAppLifecycleService lifecycleService)
{
_lifecycleService = lifecycleService;
_launcherProcessId = options.LauncherProcessId;
_requesterProcessId = options.RequesterProcessId;
_hostProcessId = options.RequesterProcessId;
_updatedAtUtc = _startedAtUtc;
}
public void AttachHost(int hostProcessId)
{
if (hostProcessId <= 0)
{
return;
}
lock (_gate)
{
_hostProcessId = hostProcessId;
_updatedAtUtc = DateTimeOffset.UtcNow;
}
AirAppRuntimeLogger.Info($"Attached host process. HostPid={hostProcessId}.");
}
public bool ShouldKeepAlive()
{
var status = GetStatus();
return status.LauncherProcessAlive ||
status.HostProcessAlive ||
IsProcessAlive(_requesterProcessId) ||
status.HasLiveAirApps;
}
public AirAppRuntimeStatus GetStatus()
{
int hostPid;
DateTimeOffset updatedAt;
lock (_gate)
{
hostPid = _hostProcessId;
updatedAt = _updatedAtUtc;
}
var launcherAlive = IsProcessAlive(_launcherProcessId);
var hostAlive = IsProcessAlive(hostPid);
var hasLiveAirApps = _lifecycleService.HasLiveAirApps();
return new AirAppRuntimeStatus(
Environment.ProcessId,
_launcherProcessId,
hostPid,
launcherAlive,
hostAlive,
hasLiveAirApps,
_startedAtUtc,
updatedAt);
}
internal static bool IsProcessAlive(int processId)
{
return AirAppLifecycleService.IsProcessAlive(processId);
}
}

View File

@@ -0,0 +1,16 @@
using System.Diagnostics;
namespace LanMountainDesktop.AirAppRuntime;
internal static class AirAppRuntimeLogger
{
public static void Info(string message) => Trace.WriteLine($"[AirAppRuntime] INFO {message}");
public static void Warn(string message) => Trace.WriteLine($"[AirAppRuntime] WARN {message}");
public static void Warn(string message, Exception ex) =>
Trace.WriteLine($"[AirAppRuntime] WARN {message} {ex}");
public static void Error(string message, Exception ex) =>
Trace.WriteLine($"[AirAppRuntime] ERROR {message} {ex}");
}

View File

@@ -0,0 +1,66 @@
using System.Globalization;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed record AirAppRuntimeOptions(
string? AppRoot,
string? DataRoot,
int LauncherProcessId,
int RequesterProcessId)
{
public static AirAppRuntimeOptions Parse(IReadOnlyList<string> args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < args.Count; index++)
{
var current = args[index];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
continue;
}
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++index];
}
else
{
values[key] = "true";
}
}
return new AirAppRuntimeOptions(
GetOptionalPath(values, "app-root"),
GetOptionalPath(values, "data-root"),
GetInt(values, "launcher-pid"),
GetInt(values, "requester-pid"));
}
private static string? GetOptionalPath(IReadOnlyDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? Path.GetFullPath(value)
: null;
}
private static int GetInt(IReadOnlyDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) &&
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
}
}

View File

@@ -1,5 +1,7 @@
using System.Diagnostics;
namespace LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.AirAppRuntime;
internal interface IAirAppProcessStarter
{
@@ -12,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
private readonly Func<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider;
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
public AirAppProcessStarter(
AirAppHostLocator locator,
Func<string?> packageRootProvider,
Func<string?> hostPathProvider,
Func<string?> dataRootProvider,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
Func<string?> dataRootProvider)
{
_locator = locator;
_packageRootProvider = packageRootProvider;
_hostPathProvider = hostPathProvider;
_dataRootProvider = dataRootProvider;
_runtimeProbeOptions = runtimeProbeOptions;
}
public Process? Start(
@@ -36,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
string? sourcePlacementId)
{
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
var startInfo = CreateStartInfo(hostPath);
AddArgument(startInfo, "--app-id", appId);
AddArgument(startInfo, "--session-id", sessionId);
AddArgument(startInfo, "--instance-key", instanceKey);
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
var dataRoot = _dataRootProvider();
if (!string.IsNullOrWhiteSpace(dataRoot))
{
@@ -58,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
}
Logger.Info(
AirAppRuntimeLogger.Info(
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
var process = Process.Start(startInfo);
if (process is not null)
@@ -68,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
{
try
{
Logger.Info(
AirAppRuntimeLogger.Info(
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
}
catch (Exception ex)
{
Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
}
};
}
@@ -81,54 +80,11 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
return process;
}
internal static ProcessStartInfo CreateStartInfo(
string hostPath,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
internal static ProcessStartInfo CreateStartInfo(string hostPath)
{
var startInfo = new ProcessStartInfo
{
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
};
if (OperatingSystem.IsWindows())
{
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
{
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
{
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
if (!executableRuntime.IsAvailable)
{
throw new InvalidOperationException(
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
executableRuntime.Message);
}
}
startInfo.FileName = hostPath;
return startInfo;
}
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
{
throw new InvalidOperationException(
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
runtime.Message);
}
startInfo.FileName = runtime.DotNetHostPath;
startInfo.ArgumentList.Add(hostPath);
return startInfo;
}
startInfo.FileName = "dotnet";
startInfo.ArgumentList.Add(hostPath);
return startInfo;
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
}
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{
startInfo.ArgumentList.Add(name);

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>false</PublishAot>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
namespace LanMountainDesktop.AirAppRuntime;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
var options = AirAppRuntimeOptions.Parse(args);
AirAppRuntimeLogger.Info(
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
try
{
var lifecycleService = new AirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => options.AppRoot,
() => null,
() => options.DataRoot));
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
var controlService = new AirAppRuntimeControlService(lifetime);
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
ipcHost.Start();
while (lifetime.ShouldKeepAlive())
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
return 0;
}
catch (Exception ex)
{
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
return 1;
}
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -1,29 +0,0 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.AirApp;
internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
{
private readonly PublicIpcHostService _host;
public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService)
{
LifecycleService = lifecycleService;
_host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName);
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
}
public LauncherAirAppLifecycleService LifecycleService { get; }
public void Start()
{
_host.Start();
Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
}
public void Dispose()
{
_host.Dispose();
}
}

View File

@@ -60,13 +60,6 @@ public partial class App : Application
return;
}
if (context.IsAirAppBrokerCommand)
{
_ = AirAppBrokerEntryHandler.RunAsync(desktop, context);
base.OnFrameworkInitializationCompleted();
return;
}
if (context.IsDebugMode && !context.IsPreviewCommand)
{
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");

View File

@@ -11,15 +11,7 @@ namespace LanMountainDesktop.Launcher;
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(PlondsUpdateMetadata))]
[JsonSerializable(typeof(PlondsFileMap))]
[JsonSerializable(typeof(PlondsComponentEntry))]
[JsonSerializable(typeof(PlondsFileEntry))]
[JsonSerializable(typeof(PlondsHashDescriptor))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(InstallCheckpoint))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
@@ -37,11 +29,11 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(StartupAttemptRecord))]
[JsonSerializable(typeof(PrivacyConfig))]
[JsonSerializable(typeof(PrivacyAgreementState))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
[JsonSerializable(typeof(AirAppOpenRequest))]
[JsonSerializable(typeof(AirAppRegistrationRequest))]
[JsonSerializable(typeof(AirAppInstanceInfo))]
[JsonSerializable(typeof(AirAppOperationResult))]
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
[JsonSerializable(typeof(AirAppRuntimeStatus))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -4,14 +4,11 @@ namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
public const string AirAppBrokerCommand = "air-app-broker";
private const string LaunchSourceOptionName = "launch-source";
private static readonly string[] GuiCommands =
[
"launch",
AirAppBrokerCommand,
"preview-splash",
"preview-error",
"preview-update",
@@ -62,15 +59,11 @@ internal sealed class CommandContext
public bool IsPreviewCommand =>
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
public bool IsAirAppBrokerCommand =>
string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
public bool IsGuiCommand =>
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
public bool IsMaintenanceCommand =>
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
public string? ExplicitAppRoot => GetOption("app-root");

View File

@@ -1,4 +1,3 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;

View File

@@ -14,7 +14,7 @@ internal static class Commands
{
var source = context.GetOption("source") ?? string.Empty;
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
result = installer.InstallPackage(source, pluginsDir);
result = installer.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
}
catch (Exception ex)
{
@@ -91,12 +91,12 @@ internal static class Commands
{
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginInstaller.InstallPackage(source, pluginsDir);
return pluginInstaller.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
}
case "update":
{
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
}
default:
return new LauncherResult

View File

@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
? Path.GetFullPath(customPath)
var targetDataRoot = mode == DataLocationMode.Portable
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath;
var config = new DataLocationConfig

View File

@@ -1,137 +0,0 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Ipc;
internal interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
{
private const int LengthPrefixSize = 4;
private readonly string _pipeName;
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipe;
private Task? _listenTask;
private volatile bool _clientConnected;
public LauncherUpdateProgressIpcServer(int launcherPid)
{
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
}
public string PipeName => _pipeName;
public void Start()
{
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
}
private async Task AcceptConnectionAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
_pipe = new NamedPipeServerStream(
_pipeName,
PipeDirection.Out,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
_clientConnected = true;
return;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
try
{
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
public void ReportProgress(InstallProgressReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
}
}
public void ReportComplete(InstallCompleteReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
}
}
private static void WriteMessage(Stream stream, string json)
{
var payload = Encoding.UTF8.GetBytes(json);
var lengthPrefix = BitConverter.GetBytes(payload.Length);
stream.Write(lengthPrefix, 0, LengthPrefixSize);
stream.Write(payload, 0, payload.Length);
stream.Flush();
}
public void Dispose()
{
_cts.Cancel();
try
{
_pipe?.Dispose();
}
catch
{
}
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch
{
}
_cts.Dispose();
}
}

View File

@@ -46,7 +46,7 @@
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
<PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
@@ -55,7 +55,7 @@
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
<PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />

View File

@@ -1,17 +0,0 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新频道
/// </summary>
public enum UpdateChannel
{
/// <summary>
/// 正式版 - 只检查 prerelease=false 的版本
/// </summary>
Stable,
/// <summary>
/// 预览版 - 检查所有版本(包括 prerelease=true)
/// </summary>
Preview
}

View File

@@ -1,13 +0,0 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新检查结果
/// </summary>
public sealed class UpdateCheckResult
{
public bool HasUpdate { get; init; }
public string? LatestVersion { get; init; }
public string? CurrentVersion { get; init; }
public ReleaseInfo? Release { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@@ -1,29 +1,5 @@
namespace LanMountainDesktop.Launcher.Models;
internal sealed class SignedFileMap
{
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public List<UpdateFileEntry> Files { get; set; } = [];
}
internal sealed class UpdateFileEntry
{
public string Path { get; set; } = string.Empty;
public string? ArchivePath { get; set; }
public string Action { get; set; } = "replace";
public string? Sha256 { get; set; }
}
internal sealed class SnapshotMetadata
{
public string SnapshotId { get; set; } = string.Empty;
@@ -40,124 +16,3 @@ internal sealed class SnapshotMetadata
public string Status { get; set; } = "pending";
}
internal sealed class InstallCheckpoint
{
public string SnapshotId { get; set; } = string.Empty;
public string SourceVersion { get; set; } = string.Empty;
public string? TargetVersion { get; set; }
public string? SourceDirectory { get; set; }
public string TargetDirectory { get; set; } = string.Empty;
public bool IsInitialDeployment { get; set; }
public int AppliedCount { get; set; }
public int VerifiedCount { get; set; }
}
internal sealed class UpdateApplyResult
{
public bool Success { get; init; }
public string Message { get; init; } = string.Empty;
public string? FromVersion { get; init; }
public string? ToVersion { get; init; }
public string? RolledBackTo { get; init; }
}
internal sealed class PlondsUpdateMetadata
{
public string? DistributionId { get; set; }
public string? Channel { get; set; }
public string? SubChannel { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? FileMapPath { get; set; }
public string? FileMapSignaturePath { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsFileMap
{
public string? DistributionId { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Version { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsComponentEntry> Components { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
public string? Version { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsFileEntry
{
public string Path { get; set; } = string.Empty;
public string? Action { get; set; } = "replace";
public string? Url { get; set; }
public string? ObjectUrl { get; set; }
public string? ObjectPath { get; set; }
public string? ObjectKey { get; set; }
public string? ArchivePath { get; set; }
public string? Sha256 { get; set; }
public string? Sha512 { get; set; }
public string? Sha512Base64 { get; set; }
public byte[]? Sha512Bytes { get; set; }
public PlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsHashDescriptor
{
public string? Algorithm { get; set; }
public string? Value { get; set; }
public byte[]? Bytes { get; set; }
}

View File

@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
TimeSpan.FromMilliseconds(500)
];
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory, string? appRoot = null)
{
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
{
return elevationRequiredResult;
}
@@ -58,7 +58,7 @@ internal sealed class PluginInstallerService
};
}
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory, string? appRoot)
{
if (!OperatingSystem.IsWindows())
{
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
string? allowedRoot = null;
try
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
? Path.GetFullPath(appRoot)
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(resolvedAppRoot);
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
}
catch

View File

@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
_installerService = installerService;
}
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
{
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
if (!File.Exists(pendingPath))
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
try
{
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
succeeded.Add(item);
}
catch

View File

@@ -57,14 +57,6 @@
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Update Check)": {
"commandName": "Project",
"commandLineArgs": "update check",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Plugin Install)": {
"commandName": "Project",
"commandLineArgs": "plugin install <path-to-plugin.laapp>",

View File

@@ -0,0 +1,83 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Shell;
internal sealed class AirAppRuntimeBridge
{
private const int ConnectAttempts = 8;
private readonly string _appRoot;
private readonly string? _dataRoot;
public AirAppRuntimeBridge(string appRoot, string? dataRoot)
{
_appRoot = appRoot;
_dataRoot = dataRoot;
}
public async Task EnsureStartedAsync()
{
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
{
Logger.Info("AirApp Runtime is already available.");
return;
}
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
_appRoot,
Environment.ProcessId,
0,
_dataRoot));
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
{
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
{
Logger.Info("AirApp Runtime IPC is ready.");
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false);
}
Logger.Warn("AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
}
public async Task AttachHostAsync(int hostProcessId)
{
if (hostProcessId <= 0)
{
return;
}
try
{
using var client = new LanMountainDesktopIpcClient();
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
var result = await proxy.AttachHostAsync(hostProcessId).ConfigureAwait(false);
Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
}
catch (Exception ex)
{
Logger.Warn($"Failed to attach Host to AirApp Runtime: {ex.Message}");
}
}
private static async Task<AirAppRuntimeStatus?> TryGetStatusAsync()
{
try
{
using var client = new LanMountainDesktopIpcClient();
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
return await proxy.GetStatusAsync().ConfigureAwait(false);
}
catch
{
return null;
}
}
}

View File

@@ -1,6 +1,4 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
@@ -30,52 +28,3 @@ internal static class LaunchEntryHandler
SplashWindow splashWindow) =>
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
}
internal static class AirAppBrokerEntryHandler
{
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
{
var appRoot = Commands.ResolveAppRoot(context);
var requesterPid = context.GetIntOption("requester-pid", 0);
var dataLocationResolver = new DataLocationResolver(appRoot);
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
new LauncherAirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => appRoot,
() => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start();
while (ShouldKeepAlive(requesterPid, airAppIpcHost.LifecycleService))
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
Logger.Info("Air APP broker exiting.");
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
}
internal static bool ShouldKeepAirAppBrokerAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService)
{
if (requesterPid <= 0)
{
return lifecycleService.HasLiveAirApps();
}
try
{
using var process = System.Diagnostics.Process.GetProcessById(requesterPid);
return !process.HasExited || lifecycleService.HasLiveAirApps();
}
catch
{
return lifecycleService.HasLiveAirApps();
}
}
private static bool ShouldKeepAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) =>
ShouldKeepAirAppBrokerAlive(requesterPid, lifecycleService);
}

View File

@@ -24,6 +24,9 @@ internal static class LauncherGuiCoordinator
var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource,
successPolicy,
@@ -44,15 +47,6 @@ internal static class LauncherGuiCoordinator
return;
}
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
new LauncherAirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => appRoot,
() => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start();
using var coordinatorServer = new LauncherCoordinatorIpcServer(
coordinatorPipeName,
BuildCoordinatorStatusFromAttempt(reservedAttempt),
@@ -129,7 +123,8 @@ internal static class LauncherGuiCoordinator
if (result.Success)
{
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
@@ -173,17 +168,15 @@ internal static class LauncherGuiCoordinator
return fallbackHostPid;
}
private static async Task WaitForManagedProcessesToExitAsync(
int hostPid,
LauncherAirAppLifecycleService airAppLifecycleService)
private static async Task WaitForHostProcessToExitAsync(int hostPid)
{
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
while (TryGetLiveProcess(hostPid))
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
Logger.Info("Launcher host background lifetime completed; host process is gone.");
}
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(

View File

@@ -0,0 +1,27 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IAirAppRuntimeControlService
{
Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId);
Task<AirAppRuntimeStatus> GetStatusAsync();
}
public sealed record AirAppRuntimeControlResult(
bool Accepted,
string Code,
string Message,
AirAppRuntimeStatus Status);
public sealed record AirAppRuntimeStatus(
int ProcessId,
int LauncherProcessId,
int HostProcessId,
bool LauncherProcessAlive,
bool HostProcessAlive,
bool HasLiveAirApps,
DateTimeOffset StartedAtUtc,
DateTimeOffset UpdatedAtUtc);

View File

@@ -0,0 +1,64 @@
using System.Text.Json;
namespace LanMountainDesktop.Shared.IPC;
public static class AirAppRuntimeDataRootResolver
{
private const string LauncherDataFolderName = ".Launcher";
private const string ConfigFileName = "data-location.config.json";
private const string DesktopFolderName = "Desktop";
public static string ResolveDataRoot(string? appRoot)
{
var defaultSystemDataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
if (string.IsNullOrWhiteSpace(appRoot))
{
return defaultSystemDataPath;
}
var normalizedAppRoot = Path.GetFullPath(appRoot);
var configPath = Path.Combine(normalizedAppRoot, LauncherDataFolderName, ConfigFileName);
if (!File.Exists(configPath))
{
return defaultSystemDataPath;
}
try
{
using var document = JsonDocument.Parse(File.ReadAllText(configPath));
var root = document.RootElement;
var mode = GetString(root, "dataLocationMode");
if (string.Equals(mode, "Portable", StringComparison.OrdinalIgnoreCase))
{
return Path.GetFullPath(
GetString(root, "portableDataPath")
?? Path.Combine(normalizedAppRoot, DesktopFolderName));
}
return Path.GetFullPath(GetString(root, "systemDataPath") ?? defaultSystemDataPath);
}
catch
{
return defaultSystemDataPath;
}
}
private static string? GetString(JsonElement element, string propertyName)
{
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind is JsonValueKind.String)
{
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
return null;
}
}

View File

@@ -0,0 +1,77 @@
namespace LanMountainDesktop.Shared.IPC;
public static class AirAppRuntimePathResolver
{
private const string WindowsExecutableName = "LanMountainDesktop.AirAppRuntime.exe";
private const string UnixExecutableName = "LanMountainDesktop.AirAppRuntime";
private const string DllName = "LanMountainDesktop.AirAppRuntime.dll";
private static string ExecutableName => OperatingSystem.IsWindows()
? WindowsExecutableName
: UnixExecutableName;
public static string? ResolveExecutablePath(string? appRoot = null, string? hostBaseDirectory = null)
{
return EnumerateCandidates(appRoot, hostBaseDirectory)
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(File.Exists);
}
public static IEnumerable<string> EnumerateCandidates(string? appRoot = null, string? hostBaseDirectory = null)
{
foreach (var root in EnumerateRoots(appRoot, hostBaseDirectory))
{
yield return Path.Combine(root, ExecutableName);
yield return Path.Combine(root, DllName);
yield return Path.Combine(root, "AirAppRuntime", ExecutableName);
yield return Path.Combine(root, "AirAppRuntime", DllName);
}
var current = new DirectoryInfo(AppContext.BaseDirectory);
for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent)
{
yield return Path.Combine(
current.FullName,
"LanMountainDesktop.AirAppRuntime",
"bin",
#if DEBUG
"Debug",
#else
"Release",
#endif
"net10.0",
ExecutableName);
yield return Path.Combine(
current.FullName,
"LanMountainDesktop.AirAppRuntime",
"bin",
#if DEBUG
"Debug",
#else
"Release",
#endif
"net10.0",
DllName);
}
}
private static IEnumerable<string> EnumerateRoots(string? appRoot, string? hostBaseDirectory)
{
if (!string.IsNullOrWhiteSpace(appRoot))
{
yield return Path.GetFullPath(appRoot);
}
if (!string.IsNullOrWhiteSpace(hostBaseDirectory))
{
var hostDirectory = Path.GetFullPath(hostBaseDirectory);
yield return hostDirectory;
yield return Path.GetFullPath(Path.Combine(hostDirectory, ".."));
}
yield return AppContext.BaseDirectory;
yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
}
}

View File

@@ -0,0 +1,101 @@
using System.Diagnostics;
using System.Globalization;
namespace LanMountainDesktop.Shared.IPC;
public sealed record AirAppRuntimeStartRequest(
string? AppRoot,
int LauncherProcessId,
int RequesterProcessId,
string? DataRoot);
public static class AirAppRuntimeProcessStarter
{
public static Process? Start(AirAppRuntimeStartRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var runtimePath = AirAppRuntimePathResolver.ResolveExecutablePath(
request.AppRoot,
AppContext.BaseDirectory);
if (string.IsNullOrWhiteSpace(runtimePath))
{
return null;
}
var startInfo = CreateStartInfo(runtimePath);
AddOptionalArgument(startInfo, "--app-root", request.AppRoot);
AddOptionalArgument(startInfo, "--data-root", request.DataRoot);
AddIntArgument(startInfo, "--launcher-pid", request.LauncherProcessId);
AddIntArgument(startInfo, "--requester-pid", request.RequesterProcessId);
return Process.Start(startInfo);
}
public static ProcessStartInfo CreateStartInfo(string runtimePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runtimePath);
var fullPath = Path.GetFullPath(runtimePath);
var startInfo = new ProcessStartInfo
{
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory
};
var extension = Path.GetExtension(fullPath);
if (OperatingSystem.IsWindows() &&
string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase))
{
startInfo.FileName = ResolveDotNetHostPath();
startInfo.ArgumentList.Add(fullPath);
return startInfo;
}
if (!OperatingSystem.IsWindows() &&
string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase))
{
startInfo.FileName = "dotnet";
startInfo.ArgumentList.Add(fullPath);
return startInfo;
}
startInfo.FileName = fullPath;
return startInfo;
}
private static string ResolveDotNetHostPath()
{
var programFiles = Environment.GetEnvironmentVariable("ProgramW6432") ??
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesCandidate = Path.Combine(programFiles, "dotnet", "dotnet.exe");
if (File.Exists(programFilesCandidate))
{
return programFilesCandidate;
}
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var perUserCandidate = Path.Combine(localAppData, "dotnet", "dotnet.exe");
return File.Exists(perUserCandidate) ? perUserCandidate : "dotnet";
}
private static void AddOptionalArgument(ProcessStartInfo startInfo, string name, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
startInfo.ArgumentList.Add(name);
startInfo.ArgumentList.Add(Path.GetFullPath(value));
}
private static void AddIntArgument(ProcessStartInfo startInfo, string name, int value)
{
if (value <= 0)
{
return;
}
startInfo.ArgumentList.Add(name);
startInfo.ArgumentList.Add(value.ToString(CultureInfo.InvariantCulture));
}
}

View File

@@ -6,7 +6,10 @@ public static class IpcConstants
public const string ProtocolVersion = "external-ipc-public-api.v1";
public const string AirAppLifecyclePipeName = "LanMountainDesktop.Launcher.AirApp.v1";
public const string AirAppRuntimePipeName = "LanMountainDesktop.AirAppRuntime.v1";
[Obsolete("Use AirAppRuntimePipeName. The lifecycle service is now hosted by LanMountainDesktop.AirAppRuntime.")]
public const string AirAppLifecyclePipeName = AirAppRuntimePipeName;
public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";

View File

@@ -96,17 +96,17 @@ public sealed class AirAppLauncherServiceTests
}
[Fact]
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
public void CreateRuntimeStartInfo_UsesAirAppRuntimeAndRequesterPid()
{
var startInfo = AirAppLauncherService.CreateBrokerStartInfo(
@"C:\Apps\LanMountainDesktop.Launcher.exe",
var startInfo = AirAppLauncherService.CreateRuntimeStartInfo(
@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe",
12345);
Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName);
Assert.Equal(@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe", startInfo.FileName);
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
Assert.False(startInfo.UseShellExecute);
Assert.Equal(
["air-app-broker", "--requester-pid", "12345"],
["--requester-pid", "12345"],
startInfo.ArgumentList);
}
}

View File

@@ -1,5 +1,3 @@
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Infrastructure;
using Xunit;
namespace LanMountainDesktop.Tests;
@@ -29,38 +27,14 @@ public sealed class AirAppProcessStarterRuntimeTests : IDisposable
}
[Fact]
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
public void CreateStartInfo_UsesDotnetHost_ForDllFallback()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var programFiles = Path.Combine(_root, "ProgramFiles");
var dotnetRoot = Path.Combine(programFiles, "dotnet");
Directory.CreateDirectory(dotnetRoot);
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
File.WriteAllText(dotnetHost, string.Empty);
Directory.CreateDirectory(Path.Combine(
dotnetRoot,
"shared",
DotNetRuntimeProbe.RequiredSharedFrameworkName,
"10.0.5"));
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
File.WriteAllText(hostDll, string.Empty);
var options = new DotNetRuntimeProbeOptions
{
Architecture = DotNetRuntimeArchitecture.X64,
ProgramFilesPath = programFiles,
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
IncludeRegistry = false,
IncludeDotNetCli = false
};
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll);
Assert.Equal(dotnetHost, startInfo.FileName);
Assert.Contains("dotnet", Path.GetFileName(startInfo.FileName), StringComparison.OrdinalIgnoreCase);
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
}

View File

@@ -0,0 +1,70 @@
using System.Text.Json;
using LanMountainDesktop.Shared.IPC;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class AirAppRuntimeDataRootResolverTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.AirAppRuntimeDataRootResolverTests",
Guid.NewGuid().ToString("N"));
[Fact]
public void ResolveDataRoot_UsesPortableDataLocationConfig()
{
var portableRoot = Path.Combine(_root, "PortableData");
WriteConfig(new
{
dataLocationMode = "Portable",
portableDataPath = portableRoot
});
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
Assert.Equal(Path.GetFullPath(portableRoot), resolved);
}
[Fact]
public void ResolveDataRoot_UsesSystemDataLocationConfig()
{
var systemRoot = Path.Combine(_root, "SystemData");
WriteConfig(new
{
dataLocationMode = "System",
systemDataPath = systemRoot
});
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
Assert.Equal(Path.GetFullPath(systemRoot), resolved);
}
[Fact]
public void ResolveDataRoot_FallsBackToDefaultWhenConfigMissing()
{
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
Assert.Equal(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LanMountainDesktop"),
resolved);
}
private void WriteConfig<T>(T config)
{
var configDirectory = Path.Combine(_root, ".Launcher");
Directory.CreateDirectory(configDirectory);
File.WriteAllText(
Path.Combine(configDirectory, "data-location.config.json"),
JsonSerializer.Serialize(config));
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
}

View File

@@ -1,20 +1,17 @@
using System.Diagnostics;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class LauncherAirAppLifecycleServiceTests
public sealed class AirAppRuntimeLifecycleServiceTests
{
[Fact]
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var service = new AirAppLifecycleService(starter);
var request = new AirAppOpenRequest(
"whiteboard",
BuiltInComponentIds.DesktopWhiteboard,
@@ -36,7 +33,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var service = new AirAppLifecycleService(starter);
var first = await service.OpenAsync(new AirAppOpenRequest(
"world-clock",
@@ -62,7 +59,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var service = new AirAppLifecycleService(starter);
var instanceKey = AirAppInstanceKey.Build(
"whiteboard",
BuiltInComponentIds.DesktopWhiteboard,
@@ -92,7 +89,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
[Fact]
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
@@ -112,26 +109,35 @@ public sealed class LauncherAirAppLifecycleServiceTests
}
[Fact]
public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive()
public void RuntimeLifetime_KeepsAliveWhileRequesterIsAlive()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, 0, Environment.ProcessId),
service);
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
Assert.True(lifetime.ShouldKeepAlive());
}
[Fact]
public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain()
public void RuntimeLifetime_StopsWhenNoProcessOrAirAppsRemain()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
service);
Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
Assert.False(lifetime.ShouldKeepAlive());
}
[Fact]
public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive()
public async Task RuntimeLifetime_KeepsAliveWhileAirAppIsAlive()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
service);
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
instanceKey,
@@ -142,28 +148,23 @@ public sealed class LauncherAirAppLifecycleServiceTests
BuiltInComponentIds.DesktopWorldClock,
"clock-2"));
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
Assert.True(lifetime.ShouldKeepAlive());
}
[Fact]
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment()
public async Task RuntimeControl_AttachesHostProcess()
{
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
service);
var control = new AirAppRuntimeControlService(lifetime);
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
var result = await control.AttachHostAsync(Environment.ProcessId);
Assert.True(context.IsGuiCommand);
Assert.True(context.IsAirAppBrokerCommand);
Assert.True(context.IsDebugMode);
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
}
Assert.True(result.Accepted);
Assert.Equal(Environment.ProcessId, result.Status.HostProcessId);
Assert.True(result.Status.HostProcessAlive);
}
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter

View File

@@ -9,7 +9,6 @@ public sealed class CommandContextTests
{
{ [], "normal" },
{ ["preview-oobe"], "debug-preview" },
{ ["apply-update"], "normal" },
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
};
@@ -22,4 +21,12 @@ public sealed class CommandContextTests
Assert.Equal(expectedLaunchSource, context.LaunchSource);
}
[Fact]
public void FromArgs_DoesNotTreatAirAppBrokerAsLauncherGuiCommand()
{
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
Assert.False(context.IsGuiCommand);
}
}

View File

@@ -0,0 +1,35 @@
using LanMountainDesktop.Launcher.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class DataLocationResolverTests : IDisposable
{
private readonly string _appRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.Tests",
nameof(DataLocationResolverTests),
Guid.NewGuid().ToString("N"));
[Fact]
public void ApplyLocationChoice_PortableWithoutCustomPath_UsesAppRootDesktopDirectory()
{
Directory.CreateDirectory(_appRoot);
var resolver = new DataLocationResolver(_appRoot);
var applied = resolver.ApplyLocationChoice(DataLocationMode.Portable);
Assert.True(applied);
Assert.Equal(
Path.Combine(Path.GetFullPath(_appRoot), "Desktop"),
resolver.ResolveDataRoot());
}
public void Dispose()
{
if (Directory.Exists(_appRoot))
{
Directory.Delete(_appRoot, recursive: true);
}
}
}

View File

@@ -35,4 +35,37 @@ public sealed class DesktopEditOverlayPresenterTests
Assert.Equal(180, ghost.Width);
Assert.Equal(120, ghost.Height);
}
[Fact]
public void CandidateRectUsesCanvasPlacement()
{
var presenter = new DesktopEditOverlayPresenter(new CompositionVisualAnimationService(_ => null));
var root = Assert.IsType<Canvas>(presenter.Root);
presenter.SetCandidateRect(new Rect(44, 58, 240, 160));
var candidate = root.Children.OfType<Border>().Single(child => child is not DesktopEditGhostView);
Assert.Equal(44, Canvas.GetLeft(candidate));
Assert.Equal(58, Canvas.GetTop(candidate));
Assert.Equal(240, candidate.Width);
Assert.Equal(160, candidate.Height);
}
[Fact]
public void ShowPreservesPreviewAndCandidateCanvasPlacement()
{
var presenter = new DesktopEditOverlayPresenter(new CompositionVisualAnimationService(_ => null));
var root = Assert.IsType<Canvas>(presenter.Root);
presenter.SetPreviewRect(new Rect(16, 32, 180, 120));
presenter.SetCandidateRect(new Rect(24, 40, 200, 140));
presenter.Show();
var ghost = root.Children.OfType<DesktopEditGhostView>().Single();
var candidate = root.Children.OfType<Border>().Single(child => child is not DesktopEditGhostView);
Assert.Equal(16, Canvas.GetLeft(ghost));
Assert.Equal(32, Canvas.GetTop(ghost));
Assert.Equal(24, Canvas.GetLeft(candidate));
Assert.Equal(40, Canvas.GetTop(candidate));
}
}

View File

@@ -1,4 +1,4 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.AirAppRuntime;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;

View File

@@ -11,7 +11,6 @@ public sealed class HostActivationPolicyTests
[Theory]
[InlineData("launch", "normal", true)]
[InlineData("launch", "restart", false)]
[InlineData("apply-update", "normal", false)]
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
string command,
string launchSource,

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
</ItemGroup>
</Project>

View File

@@ -47,6 +47,95 @@ public sealed class LauncherArchitectureTests
Assert.Empty(offenders);
}
[Fact]
public void LauncherProject_DoesNotOwnUpdateApplyOrRollback()
{
var launcherFiles = Directory
.EnumerateFiles(LauncherProjectRoot, "*.cs", SearchOption.AllDirectories)
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
.ToArray();
var forbiddenTokens = new[]
{
"LauncherUpdateCommandExecutor",
"PlondsUpdateApplier",
"UpdateRollbackGateway",
"UpdateInstallGateway",
"LanMountainDesktop.Services.Update",
"apply-update",
"rollback --app-root"
};
var offenders = launcherFiles
.SelectMany(file => forbiddenTokens
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
.ToArray();
Assert.Empty(offenders);
}
[Fact]
public void LauncherProjectFile_DoesNotSourceLinkHostUpdateImplementation()
{
var project = File.ReadAllText(Path.Combine(LauncherProjectRoot, "LanMountainDesktop.Launcher.csproj"));
Assert.DoesNotContain(@"..\LanMountainDesktop\Services\Update", project, StringComparison.Ordinal);
Assert.DoesNotContain("PlondsUpdateApplier", project, StringComparison.Ordinal);
Assert.DoesNotContain("UpdateRollbackGateway", project, StringComparison.Ordinal);
Assert.DoesNotContain("UpdateInstallGateway", project, StringComparison.Ordinal);
}
[Fact]
public void HostUpdateFlow_DoesNotDelegateApplyOrRollbackToLauncher()
{
var guardedFiles = new[]
{
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateInstallGateway.cs"),
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateOrchestrator.cs")
};
var forbiddenTokens = new[]
{
"LauncherPathResolver",
"ResolveLauncherExecutablePath",
"apply-update",
"rollback --app-root",
"Launched Launcher"
};
var offenders = guardedFiles
.SelectMany(file => forbiddenTokens
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
.ToArray();
Assert.Empty(offenders);
}
[Fact]
public void HostUpdateFlow_OwnsDeltaApplyAndRollbackExecution()
{
var installGateway = File.ReadAllText(Path.Combine(
RepoRoot,
"LanMountainDesktop",
"Services",
"Update",
"UpdateInstallGateway.cs"));
var orchestrator = File.ReadAllText(Path.Combine(
RepoRoot,
"LanMountainDesktop",
"Services",
"Update",
"UpdateOrchestrator.cs"));
Assert.Contains("new PlondsUpdateApplier", installGateway, StringComparison.Ordinal);
Assert.Contains("DeploymentLockService.ClearLock", installGateway, StringComparison.Ordinal);
Assert.Contains("new UpdateRollbackGateway().RollbackLatest", orchestrator, StringComparison.Ordinal);
Assert.DoesNotContain("LanMountainDesktop.Launcher", orchestrator, StringComparison.Ordinal);
}
[Fact]
public void LauncherCompositionRootStaysThin()
{

View File

@@ -1,4 +1,4 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.AirAppRuntime;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;

View File

@@ -0,0 +1,59 @@
using System.Text.Json;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Infrastructure;
using LanMountainDesktop.Launcher.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class LauncherUpdateCommandTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.LauncherUpdateCommandTests",
Guid.NewGuid().ToString("N"));
[Fact]
public async Task ApplyUpdateCommand_IsNotHandledByLauncherCli()
{
Directory.CreateDirectory(_root);
var resultPath = Path.Combine(_root, "result.json");
var context = CommandContext.FromArgs(["apply-update", "--app-root", _root, "--result", resultPath]);
var exitCode = await Commands.RunCliCommandAsync(context);
var result = ReadResult(resultPath);
Assert.Equal(1, exitCode);
Assert.Equal("command", result.Stage);
Assert.Equal("unsupported_command", result.Code);
}
[Fact]
public async Task RollbackCommand_IsNotHandledByLauncherCli()
{
Directory.CreateDirectory(_root);
var resultPath = Path.Combine(_root, "result.json");
var context = CommandContext.FromArgs(["rollback", "--app-root", _root, "--result", resultPath]);
var exitCode = await Commands.RunCliCommandAsync(context);
var result = ReadResult(resultPath);
Assert.Equal(1, exitCode);
Assert.Equal("command", result.Stage);
Assert.Equal("unsupported_command", result.Code);
}
private static LauncherResult ReadResult(string path)
{
var result = JsonSerializer.Deserialize<LauncherResult>(File.ReadAllText(path));
return result ?? throw new InvalidOperationException("Launcher result was not written.");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
}

View File

@@ -0,0 +1,292 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts;
using LanMountainDesktop.ViewModels;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class MaterialColorSettingsPageViewModelTests
{
[Fact]
public void Load_SelectsSavedNoneMaterialMode()
{
var facade = new FakeSettingsFacade(CreateThemeState(ThemeAppearanceValues.MaterialNone));
var materialService = new FakeMaterialColorService(CreateSnapshot(ThemeAppearanceValues.MaterialNone));
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
Assert.Equal(ThemeAppearanceValues.MaterialNone, viewModel.SelectedSystemMaterialMode.Value);
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialAuto);
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialNone);
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialMica);
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialAcrylic);
Assert.Equal(0, facade.ThemeSaveCount);
}
[Fact]
public void MaterialSnapshotRefresh_KeepsExplicitNoneSelection()
{
var facade = new FakeSettingsFacade(CreateThemeState(ThemeAppearanceValues.MaterialNone));
var materialService = new FakeMaterialColorService(CreateSnapshot(ThemeAppearanceValues.MaterialNone));
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
materialService.RaiseChanged(CreateSnapshot(ThemeAppearanceValues.MaterialAuto));
Assert.Equal(ThemeAppearanceValues.MaterialNone, viewModel.SelectedSystemMaterialMode.Value);
Assert.Equal(0, facade.ThemeSaveCount);
}
[Theory]
[InlineData(ThemeAppearanceValues.MaterialNone)]
[InlineData(ThemeAppearanceValues.MaterialAuto)]
[InlineData(ThemeAppearanceValues.MaterialMica)]
[InlineData(ThemeAppearanceValues.MaterialAcrylic)]
public void UserSelection_SavesRequestedMaterialMode(string targetMode)
{
var initialMode = targetMode == ThemeAppearanceValues.MaterialNone
? ThemeAppearanceValues.MaterialAuto
: ThemeAppearanceValues.MaterialNone;
var facade = new FakeSettingsFacade(CreateThemeState(initialMode));
var materialService = new FakeMaterialColorService(CreateSnapshot(initialMode));
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
viewModel.SelectedSystemMaterialMode = viewModel.SystemMaterialModes.Single(option =>
option.Value == targetMode);
Assert.Equal(targetMode, facade.ThemeState.SystemMaterialMode);
Assert.Equal(1, facade.ThemeSaveCount);
}
[Fact]
public void UserSelection_SystemMaterialModeRequestsRestart()
{
var facade = new FakeSettingsFacade(CreateThemeState(ThemeAppearanceValues.MaterialNone));
var materialService = new FakeMaterialColorService(CreateSnapshot(ThemeAppearanceValues.MaterialNone));
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
string? restartReason = null;
viewModel.RestartRequested += reason => restartReason = reason;
viewModel.SelectedSystemMaterialMode = viewModel.SystemMaterialModes.Single(option =>
option.Value == ThemeAppearanceValues.MaterialMica);
Assert.Equal(viewModel.SystemMaterialRestartMessage, restartReason);
Assert.False(string.IsNullOrWhiteSpace(restartReason));
}
private static ThemeAppearanceSettingsState CreateThemeState(string materialMode)
{
return new ThemeAppearanceSettingsState(
IsNightMode: false,
ThemeColor: "#FF445566",
UseSystemChrome: false,
CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleRounded,
ThemeColorMode: ThemeAppearanceValues.ColorModeDefaultNeutral,
SystemMaterialMode: materialMode,
SelectedWallpaperSeed: null,
ThemeMode: ThemeAppearanceValues.ThemeModeLight,
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
UseNativeWallpaperChangeEvents: true);
}
private static MaterialColorSnapshot CreateSnapshot(string materialMode)
{
var seed = Color.Parse("#FF3B82F6");
var palette = new LanMountainDesktop.Models.MaterialColorPalette(
seed,
Color.Parse("#FF64748B"),
seed,
Colors.White,
Color.Parse("#FF60A5FA"),
Color.Parse("#FF93C5FD"),
Color.Parse("#FFBFDBFE"),
Color.Parse("#FF2563EB"),
Color.Parse("#FF1D4ED8"),
Color.Parse("#FF1E40AF"),
Color.Parse("#FFF8FAFC"),
Color.Parse("#FFFFFFFF"),
Color.Parse("#FFF1F5F9"),
Color.Parse("#FF0F172A"),
Color.Parse("#FF334155"),
Color.Parse("#FF64748B"),
seed,
Color.Parse("#FF0F172A"),
Colors.White,
seed,
Color.Parse("#22000000"),
Color.Parse("#33000000"),
Color.Parse("#443B82F6"),
seed,
Color.Parse("#4464748B"),
Color.Parse("#663B82F6"));
var surface = new MaterialSurfaceSnapshot(
MaterialSurfaceRole.SettingsWindowBackground,
Color.Parse("#FFF8FAFC"),
Color.Parse("#22000000"),
0,
1);
var surfaces = new Dictionary<MaterialSurfaceRole, MaterialSurfaceSnapshot>
{
[MaterialSurfaceRole.SettingsWindowBackground] = surface,
[MaterialSurfaceRole.DockBackground] = surface with { Role = MaterialSurfaceRole.DockBackground },
[MaterialSurfaceRole.DesktopComponentHost] = surface with { Role = MaterialSurfaceRole.DesktopComponentHost },
[MaterialSurfaceRole.OverlayPanel] = surface with { Role = MaterialSurfaceRole.OverlayPanel }
};
return new MaterialColorSnapshot(
IsNightMode: false,
ThemeColorMode: ThemeAppearanceValues.ColorModeDefaultNeutral,
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
ColorSourceKind: MaterialColorSourceKind.Neutral,
ResolvedSeedSource: "neutral",
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
new CornerRadius(2),
new CornerRadius(4),
new CornerRadius(6),
new CornerRadius(8),
new CornerRadius(10),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(8)),
UserThemeColor: seed.ToString(),
SelectedWallpaperSeed: null,
EffectiveSeedColor: seed,
AccentColor: seed,
MonetPalette: new MonetPalette([seed], seed, seed, seed, seed, seed, seed),
Palette: palette,
WallpaperSeedCandidates: [seed],
SystemMaterialMode: materialMode,
AvailableSystemMaterialModes:
[
ThemeAppearanceValues.MaterialAuto,
ThemeAppearanceValues.MaterialNone,
ThemeAppearanceValues.MaterialMica,
ThemeAppearanceValues.MaterialAcrylic
],
CanChangeSystemMaterial: true,
UseSystemChrome: false,
ResolvedWallpaperPath: null,
UseNativeWallpaperChangeEvents: true,
NativeWallpaperChangeEventsActive: false,
WallpaperPollingActive: false,
Surfaces: surfaces);
}
private sealed class FakeSettingsFacade(ThemeAppearanceSettingsState themeState) : ISettingsFacadeService
{
private readonly FakeThemeAppearanceService _theme = new(themeState);
private readonly FakeRegionSettingsService _region = new();
private readonly FakeWallpaperSettingsService _wallpaper = new();
public ThemeAppearanceSettingsState ThemeState => _theme.State;
public int ThemeSaveCount => _theme.SaveCount;
public ISettingsService Settings => throw new NotSupportedException();
public ISettingsCatalog Catalog => throw new NotSupportedException();
public IGridSettingsService Grid => throw new NotSupportedException();
public IWallpaperSettingsService Wallpaper => _wallpaper;
public IWallpaperMediaService WallpaperMedia => throw new NotSupportedException();
public IThemeAppearanceService Theme => _theme;
public IStatusBarSettingsService StatusBar => throw new NotSupportedException();
public ITextCapsuleSettingsService TextCapsule => throw new NotSupportedException();
public IWeatherSettingsService Weather => throw new NotSupportedException();
public IRegionSettingsService Region => _region;
public IPrivacySettingsService Privacy => throw new NotSupportedException();
public IUpdateSettingsService Update => throw new NotSupportedException();
public ILauncherCatalogService LauncherCatalog => throw new NotSupportedException();
public ILauncherPolicyService LauncherPolicy => throw new NotSupportedException();
public IPluginManagementSettingsService PluginManagement => throw new NotSupportedException();
public IPluginCatalogSettingsService PluginCatalog => throw new NotSupportedException();
public IApplicationInfoService ApplicationInfo => throw new NotSupportedException();
}
private sealed class FakeThemeAppearanceService(ThemeAppearanceSettingsState state) : IThemeAppearanceService
{
public ThemeAppearanceSettingsState State { get; private set; } = state;
public int SaveCount { get; private set; }
public ThemeAppearanceSettingsState Get() => State;
public void Save(ThemeAppearanceSettingsState state)
{
SaveCount++;
State = state;
}
public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null)
{
var seed = Color.Parse(preferredSeedColor ?? "#FF3B82F6");
return new MonetPalette([seed], seed, seed, seed, seed, seed, seed);
}
}
private sealed class FakeRegionSettingsService : IRegionSettingsService
{
public RegionSettingsState Get() => new("en-US", null);
public void Save(RegionSettingsState state)
{
_ = state;
}
public TimeZoneService GetTimeZoneService() => new();
}
private sealed class FakeWallpaperSettingsService : IWallpaperSettingsService
{
public WallpaperSettingsState Get() => new(null, "SolidColor", "#FFFFFFFF", "Fill", 300);
public void Save(WallpaperSettingsState state)
{
_ = state;
}
}
private sealed class FakeMaterialColorService(MaterialColorSnapshot snapshot) : IMaterialColorService
{
private MaterialColorSnapshot _snapshot = snapshot;
public event EventHandler<MaterialColorSnapshot>? MaterialColorChanged;
public MaterialColorSnapshot GetMaterialColorSnapshot() => _snapshot;
public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState)
{
_ = pendingState;
return _snapshot;
}
public void ApplyThemeResources(IResourceDictionary resources)
{
_ = resources;
}
public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role)
{
return _snapshot.Surfaces[role];
}
public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)
{
_ = window;
_ = role;
}
public void RefreshWallpaperColors()
{
}
public void RaiseChanged(MaterialColorSnapshot snapshot)
{
_snapshot = snapshot;
MaterialColorChanged?.Invoke(this, snapshot);
}
}
}

View File

@@ -10,10 +10,12 @@ public sealed class PackagingRuntimePolicyTests
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
Assert.Contains("Publish-LauncherPayload", script);
Assert.Contains("Publish-AirAppRuntimePayload", script);
Assert.Contains("\"app-$Version\"", script);
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
Assert.Contains("\"--self-contained\", \"false\"", script);
Assert.Contains("\"-p:SelfContained=false\"", script);
Assert.Contains("\"-p:PublishAot=false\"", script);
}
[Fact]
@@ -28,12 +30,13 @@ public sealed class PackagingRuntimePolicyTests
}
[Fact]
public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost()
public void WindowsPayloadGuard_RequiresLauncherRuntimeMainAndAirAppHost()
{
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", script);
Assert.Contains("LanMountainDesktop.exe", script);
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
}
@@ -44,9 +47,21 @@ public sealed class PackagingRuntimePolicyTests
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
Assert.Contains("Verify Windows app host payload", workflow);
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", workflow);
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
}
[Fact]
public void AirAppRuntimeProject_IsFrameworkDependentJit()
{
var project = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "LanMountainDesktop.AirAppRuntime.csproj");
Assert.Contains("<PublishAot>false</PublishAot>", project);
Assert.Contains("<SelfContained>false</SelfContained>", project);
Assert.Contains("<PublishTrimmed>false</PublishTrimmed>", project);
Assert.Contains("<PublishReadyToRun>false</PublishReadyToRun>", project);
}
[Fact]
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
{

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

@@ -1,5 +1,6 @@
using LanMountainDesktop.Launcher.Plugins;
using System.IO.Compression;
using System.Text.Json;
using Xunit;
namespace LanMountainDesktop.Tests;
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
Directory.CreateDirectory(_tempRoot);
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
var pluginsDirectory = CreateUserScopedPluginsDirectory();
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService();
var result = service.InstallPackage(packagePath, pluginsDirectory);
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
Assert.True(result.Success);
Assert.Equal("ok", result.Code);
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
}
[Fact]
public void InstallPackage_AllowsConfiguredPortableDataRootOutsideUserScope()
{
if (!OperatingSystem.IsWindows())
{
return;
}
Directory.CreateDirectory(_tempRoot);
var appRoot = Path.Combine(_tempRoot, "PackageRoot");
var portableDataRoot = Path.Combine(appRoot, "Desktop");
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
Directory.CreateDirectory(launcherDataRoot);
File.WriteAllText(
Path.Combine(launcherDataRoot, "data-location.config.json"),
JsonSerializer.Serialize(new
{
DataLocationMode = "Portable",
SystemDataPath = Path.Combine(_tempRoot, "System"),
PortableDataPath = portableDataRoot
}));
var packagePath = Path.Combine(_tempRoot, "portable.laapp");
CreatePluginPackage(packagePath, "plugin.json", "plugin.portable.sample", "Portable Plugin");
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
var service = new PluginInstallerService();
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
Assert.True(result.Success);
Assert.Equal("ok", result.Code);
Assert.True(File.Exists(result.InstalledPackagePath));
Assert.StartsWith(Path.GetFullPath(portableDataRoot), Path.GetFullPath(result.InstalledPackagePath!), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
{
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
var pluginsDirectory = CreateUserScopedPluginsDirectory();
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService();
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
Assert.True(first.Success);
Assert.True(second.Success);
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
Directory.CreateDirectory(_tempRoot);
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
var pluginsDirectory = CreateUserScopedPluginsDirectory();
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService();
var result = service.InstallPackage(packagePath, pluginsDirectory);
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
Assert.True(result.Success);
Assert.Equal("plugin.legacy.sample", result.ManifestId);
@@ -103,18 +140,24 @@ public sealed class PluginInstallerServiceTests : IDisposable
""");
}
private static string CreateUserScopedPluginsDirectory()
private string CreateConfiguredPortablePluginsDirectory(out string appRoot)
{
var root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Tests",
nameof(PluginInstallerServiceTests),
Guid.NewGuid().ToString("N"),
"Extensions",
"Plugins");
Directory.CreateDirectory(root);
return root;
appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
var portableDataRoot = Path.Combine(appRoot, "Desktop");
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
Directory.CreateDirectory(launcherDataRoot);
File.WriteAllText(
Path.Combine(launcherDataRoot, "data-location.config.json"),
JsonSerializer.Serialize(new
{
DataLocationMode = "Portable",
SystemDataPath = Path.Combine(_tempRoot, "System"),
PortableDataPath = portableDataRoot
}));
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
Directory.CreateDirectory(pluginsDirectory);
return pluginsDirectory;
}
public void Dispose()

View File

@@ -0,0 +1,40 @@
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class PluginRuntimeDataPathTests : IDisposable
{
private readonly string _dataRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.Tests",
nameof(PluginRuntimeDataPathTests),
Guid.NewGuid().ToString("N"));
[Fact]
public void PluginRuntime_UsesHostDataRootForPluginsAndMarketData()
{
AppDataPathProvider.Initialize(["--data-root", _dataRoot]);
using var runtime = new PluginRuntimeService();
Assert.Equal(
Path.Combine(Path.GetFullPath(_dataRoot), "Extensions", "Plugins"),
runtime.PluginsDirectory);
}
public void Dispose()
{
AppDataPathProvider.ResetForTests();
try
{
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, recursive: true);
}
}
catch
{
}
}
}

View File

@@ -0,0 +1,93 @@
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class SettingsWindowShellVisualTests
{
[Fact]
public void SettingsWindow_UsesOneFullWindowBackgroundBehindTitlebarAndContent()
{
var xaml = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml");
Assert.Contains("x:Name=\"RootGrid\"", xaml);
Assert.Contains("Background=\"Transparent\"", ExtractElementStart(xaml, "<Grid x:Name=\"RootGrid\""));
Assert.Contains("Grid.RowSpan=\"2\"", xaml);
Assert.Contains("Background=\"{DynamicResource AdaptiveSettingsWindowBackgroundBrush}\"", xaml);
Assert.Contains("Background=\"{DynamicResource AdaptiveSettingsWindowTintBrush}\"", xaml);
}
[Fact]
public void SettingsWindow_TitlebarDoesNotPaintASeparateSurfaceBand()
{
var xaml = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml");
var titlebar = ExtractElementStart(xaml, "<Border x:Name=\"WindowTitleBarHost\"");
Assert.Contains("Background=\"Transparent\"", titlebar);
Assert.Contains("BorderBrush=\"Transparent\"", titlebar);
Assert.Contains("BorderThickness=\"0\"", titlebar);
Assert.DoesNotContain("BorderThickness=\"0,0,0,1\"", titlebar);
Assert.DoesNotContain("AdaptiveSettingsWindowBackgroundBrush", titlebar);
}
[Fact]
public void SettingsWindow_NavigationShellBackgroundsAreTransparent()
{
var xaml = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml");
Assert.Contains("Classes=\"settings-navigation-view\"", xaml);
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewContentBackground\" Color=\"Transparent\" />", xaml);
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewContentGridBorderBrush\" Color=\"Transparent\" />", xaml);
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewDefaultPaneBackground\" Color=\"Transparent\" />", xaml);
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewExpandedPaneBackground\" Color=\"Transparent\" />", xaml);
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewTopPaneBackground\" Color=\"Transparent\" />", xaml);
}
[Fact]
public void NavigationStyles_KeepSettingsNavigationTemplateTransparent()
{
var styles = ReadRepositoryFile("LanMountainDesktop", "Styles", "NavigationStyles.axaml");
Assert.Contains("ui|FANavigationView.settings-navigation-view", styles);
Assert.Contains("Grid#RootGrid", styles);
Assert.Contains("Grid#ContentGrid", styles);
Assert.Contains("Grid#PaneRoot", styles);
Assert.Contains("Border#NavigationViewBorder", styles);
Assert.Contains("Border#ContentGridBorder", styles);
Assert.Contains("Border#PaneBorder", styles);
Assert.Contains("<Setter Property=\"Background\" Value=\"Transparent\" />", styles);
Assert.Contains("<Setter Property=\"BorderBrush\" Value=\"Transparent\" />", styles);
}
private static string ExtractElementStart(string source, string startToken)
{
var start = source.IndexOf(startToken, StringComparison.Ordinal);
Assert.True(start >= 0, $"Could not find '{startToken}'.");
var end = source.IndexOf('>', start);
Assert.True(end > start, $"Could not find end of '{startToken}'.");
return source.Substring(start, end - start + 1);
}
private static string ReadRepositoryFile(params string[] segments)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
{
break;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
}
}

View File

@@ -0,0 +1,103 @@
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class SystemChromeModeTests
{
[Fact]
public void SettingsWindow_SystemChromeUsesNativeDecorations()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml.cs");
var applyChromeMode = ExtractMethodSource(source, "ApplyChromeMode");
var onLoaded = ExtractMethodSource(source, "OnLoaded");
Assert.Contains("_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();", applyChromeMode);
Assert.Contains("WindowDecorations = WindowDecorations.Full;", applyChromeMode);
Assert.Contains("ExtendClientAreaToDecorationsHint = !_useSystemChrome;", applyChromeMode);
Assert.Contains("ExtendClientAreaTitleBarHeightHint = _useSystemChrome ? 0d : CustomTitleBarHeight;", applyChromeMode);
Assert.Contains("TitleBar.ExtendsContentIntoTitleBar = !_useSystemChrome;", applyChromeMode);
Assert.Contains("WindowTitleBarHost.IsVisible = false;", applyChromeMode);
Assert.Contains("WindowTitleBarHost.IsVisible = true;", applyChromeMode);
Assert.DoesNotContain("TitleBar.ExtendsContentIntoTitleBar = true;", onLoaded);
}
[Fact]
public void ComponentEditorWindow_SystemChromeUsesNativeDecorations()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "ComponentEditorWindow.axaml.cs");
var applyChromeMode = ExtractMethodSource(source, "ApplyChromeMode");
Assert.Contains("var preferSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();", applyChromeMode);
Assert.Contains("WindowDecorations = WindowDecorations.Full;", applyChromeMode);
Assert.Contains("ExtendClientAreaToDecorationsHint = false;", applyChromeMode);
Assert.Contains("ExtendClientAreaTitleBarHeightHint = 0d;", applyChromeMode);
Assert.Contains("CustomTitleBarHost.IsVisible = false;", applyChromeMode);
Assert.Contains("WindowDecorations = WindowDecorations.BorderOnly;", applyChromeMode);
Assert.Contains("ExtendClientAreaToDecorationsHint = true;", applyChromeMode);
Assert.Contains("CustomTitleBarHost.IsVisible = true;", applyChromeMode);
}
[Fact]
public void SavingSystemChromeSynchronizesWindowsPatcherState()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "Settings", "SettingsDomainServices.cs");
Assert.Contains("if (OperatingSystem.IsWindows())", source);
Assert.Contains("LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome = state.UseSystemChrome;", source);
}
private static string ReadRepositoryFile(params string[] segments)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
{
break;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
}
private static string ExtractMethodSource(string source, string methodName)
{
var methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
if (methodIndex < 0)
{
methodIndex = source.IndexOf($"public void {methodName}(", StringComparison.Ordinal);
}
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
var braceIndex = source.IndexOf('{', methodIndex);
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
var depth = 0;
for (var i = braceIndex; i < source.Length; i++)
{
if (source[i] == '{')
{
depth++;
}
else if (source[i] == '}')
{
depth--;
if (depth == 0)
{
return source.Substring(methodIndex, i - methodIndex + 1);
}
}
}
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
}
}

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

@@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests
{
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "AirApp", "IAirAppProcessStarter.cs");
var starterSource = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "IAirAppProcessStarter.cs");
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
Assert.Contains("DataRoot", optionsSource);

View File

@@ -10,6 +10,7 @@
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj" />
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />

View File

@@ -0,0 +1,12 @@
using Avalonia;
using Avalonia.Threading;
namespace LanMountainDesktop.DesktopEditing;
internal static class DesktopEditAnimationRuntime
{
public static bool CanUseTransitions()
{
return Application.Current is not null && Dispatcher.UIThread.CheckAccess();
}
}

View File

@@ -52,7 +52,7 @@ internal sealed class DesktopEditGhostView : Border
ClipToBounds = true;
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
RenderTransform = _scaleTransform;
if (Dispatcher.UIThread.CheckAccess())
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
Transitions = new Transitions
{
@@ -301,6 +301,12 @@ internal sealed class DesktopEditGhostView : Border
internal void SetScaleTransitionDuration(TimeSpan duration)
{
if (!DesktopEditAnimationRuntime.CanUseTransitions())
{
_scaleTransform.Transitions = null;
return;
}
_scaleTransform.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, duration),
@@ -310,6 +316,12 @@ internal sealed class DesktopEditGhostView : Border
internal void SetOpacityTransitionDuration(TimeSpan duration)
{
if (!DesktopEditAnimationRuntime.CanUseTransitions())
{
Transitions = null;
return;
}
Transitions = new Transitions
{
CreateOpacityTransition(duration)

View File

@@ -33,8 +33,6 @@ internal sealed class DesktopEditOverlayPresenter
private Rect? _candidateRect;
private bool _isInvalid;
private bool _isVisible;
private bool _ghostUsesCompositionOffset;
private bool _candidateUsesCompositionOffset;
private int _dismissVersion;
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
@@ -68,7 +66,7 @@ internal sealed class DesktopEditOverlayPresenter
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
RenderTransform = _candidateScale
};
if (Dispatcher.UIThread.CheckAccess())
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
_candidateOutline.Transitions = new Transitions
{
@@ -102,7 +100,7 @@ internal sealed class DesktopEditOverlayPresenter
}
};
if (Dispatcher.UIThread.CheckAccess())
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
_root.Transitions = new Transitions
{
@@ -170,21 +168,24 @@ internal sealed class DesktopEditOverlayPresenter
targetGhostScale = 1.03;
}
_root.Transitions = new Transitions
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
CreateOpacityTransition(PickupDuration)
};
_ghostView.SetOpacityTransitionDuration(PickupDuration);
_ghostView.SetScaleTransitionDuration(PickupDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
_root.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
_ghostView.SetOpacityTransitionDuration(PickupDuration);
_ghostView.SetScaleTransitionDuration(PickupDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
}
_ghostView.SetRestingScale(initialGhostScale);
_candidateOutline.Opacity = 0;
_candidateScale.ScaleX = 0.97;
@@ -243,21 +244,24 @@ internal sealed class DesktopEditOverlayPresenter
var version = ++_dismissVersion;
_isVisible = false;
var settleDuration = isCancel ? CancelSettleDuration : CommitSettleDuration;
_root.Transitions = new Transitions
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
CreateOpacityTransition(settleDuration)
};
_ghostView.SetOpacityTransitionDuration(settleDuration);
_ghostView.SetScaleTransitionDuration(settleDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
_root.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
_ghostView.SetOpacityTransitionDuration(settleDuration);
_ghostView.SetScaleTransitionDuration(settleDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
}
var targetScale = _ghostView.HasPreviewImage
? 1.00
: isCancel ? 0.96 : 1.04;
@@ -292,7 +296,7 @@ internal sealed class DesktopEditOverlayPresenter
var rect = _previewRect.Value;
_ghostView.Width = Math.Max(1, rect.Width);
_ghostView.Height = Math.Max(1, rect.Height);
SetOverlayOffset(_ghostView, new Point(rect.X, rect.Y), ref _ghostUsesCompositionOffset);
SetOverlayOffset(_ghostView, new Point(rect.X, rect.Y));
_ghostView.UpdatePreviewMetrics(rect.Width, rect.Height);
}
@@ -309,7 +313,7 @@ internal sealed class DesktopEditOverlayPresenter
_candidateOutline.IsVisible = true;
_candidateOutline.Width = Math.Max(1, rect.Width);
_candidateOutline.Height = Math.Max(1, rect.Height);
SetOverlayOffset(_candidateOutline, new Point(rect.X, rect.Y), ref _candidateUsesCompositionOffset);
SetOverlayOffset(_candidateOutline, new Point(rect.X, rect.Y));
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
@@ -339,24 +343,11 @@ internal sealed class DesktopEditOverlayPresenter
return new Rect(rect.X, rect.Y, width, height);
}
private void SetOverlayOffset(Control target, Point position, ref bool usesCompositionOffset)
private void SetOverlayOffset(Control target, Point position)
{
if (_visualAnimationService.TrySetOffset(target, position))
{
Canvas.SetLeft(target, 0);
Canvas.SetTop(target, 0);
usesCompositionOffset = true;
return;
}
if (usesCompositionOffset)
{
_visualAnimationService.TryResetOffset(target);
usesCompositionOffset = false;
}
Canvas.SetLeft(target, position.X);
Canvas.SetTop(target, position.Y);
_visualAnimationService.TryResetOffset(target);
}
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>

View File

@@ -394,8 +394,6 @@
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
"settings.appearance.corner_radius.label": "Global corner radius style",
"settings.appearance.corner_radius.description": "Select a fixed corner radius style inspired by Xiaomi HyperOS.",
"component.color_scheme.follow_system": "Follow system color scheme",
"component.color_scheme.native": "Use component custom color scheme",
"component.settings.color_scheme": "Color Scheme",
@@ -406,7 +404,7 @@
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
"settings.appearance.system_material_desc.auto": "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable.",
"settings.appearance.restart_message": "Theme source and system material changes require restarting the app.",
"settings.appearance.restart_message": "Window chrome changes require restarting the app.",
"settings.appearance.preview.primary": "Primary",
"settings.appearance.preview.secondary": "Secondary",
"settings.appearance.preview.tertiary": "Tertiary",
@@ -442,6 +440,7 @@
"settings.material_color.wallpaper_seed.label": "Seed",
"settings.material_color.system_material.label": "System material",
"settings.material_color.system_material.description": "Apply the selected material mode to windows and host surfaces.",
"settings.material_color.system_material.restart_message": "System material changes require restarting the app.",
"settings.material_color.native_events.label": "Native wallpaper change events",
"settings.material_color.native_events.description": "Use OS wallpaper notifications first and keep polling as fallback.",
"settings.material_color.native_events.active": "Native wallpaper events active",

View File

@@ -394,8 +394,6 @@
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
"settings.appearance.corner_radius.label": "全局圆角样式",
"settings.appearance.corner_radius.description": "选择固定的全局圆角样式,受 HyperOS 启发。",
"component.color_scheme.follow_system": "跟随系统配色",
"component.color_scheme.native": "使用组件自定义配色",
"component.settings.color_scheme": "配色方案",
@@ -406,7 +404,7 @@
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
"settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica在 Windows 10 优先使用 Acrylic不可用时回退到无材质。",
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
"settings.appearance.restart_message": "窗口边框模式更改需要重启应用。",
"settings.appearance.preview.primary": "主色",
"settings.appearance.preview.secondary": "次色",
"settings.appearance.preview.tertiary": "三次色",
@@ -442,6 +440,7 @@
"settings.material_color.wallpaper_seed.label": "种子色",
"settings.material_color.system_material.label": "系统材质",
"settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。",
"settings.material_color.system_material.restart_message": "系统材质更改需要重启应用。",
"settings.material_color.native_events.label": "原生壁纸变更事件",
"settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。",
"settings.material_color.native_events.active": "原生壁纸事件已激活",

View File

@@ -22,7 +22,7 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
public const string WorldClockAppId = "world-clock";
public const string WhiteboardAppId = "whiteboard";
private const int LauncherIpcRetryCount = 4;
private const int RuntimeIpcRetryCount = 4;
public void OpenWorldClock(string? sourcePlacementId)
{
@@ -82,27 +82,27 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
if (result.Accepted)
{
AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
AppLogger.Info("AirAppLauncher", $"AirApp Runtime accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
return;
}
AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
AppLogger.Warn("AirAppLauncher", $"AirApp Runtime rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
}
catch (Exception ex)
{
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex);
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through AirApp Runtime. AppId='{appId}'.", ex);
}
}
private static async Task<AirAppOperationResult> SendOpenRequestAsync(AirAppOpenRequest request)
{
Exception? lastException = null;
for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++)
for (var attempt = 1; attempt <= RuntimeIpcRetryCount; attempt++)
{
try
{
using var client = new LanMountainDesktopIpcClient();
await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false);
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
var proxy = client.CreateProxy<IAirAppLifecycleService>();
return await proxy.OpenAsync(request).ConfigureAwait(false);
}
@@ -113,9 +113,9 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
{
AppLogger.Warn(
"AirAppLauncher",
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.",
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppRuntimePipeName}'. Starting AirApp Runtime.",
ex);
TryStartLauncher();
TryStartRuntime();
}
await Task.Delay(250 * attempt).ConfigureAwait(false);
@@ -123,44 +123,52 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
}
throw new InvalidOperationException(
$"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.",
$"AirApp Runtime IPC is unavailable. Pipe='{IpcConstants.AirAppRuntimePipeName}'.",
lastException);
}
internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId)
internal static ProcessStartInfo CreateRuntimeStartInfo(string runtimePath, int requesterProcessId, string? appRoot = null, string? dataRoot = null)
{
var startInfo = new ProcessStartInfo
var startInfo = AirAppRuntimeProcessStarter.CreateStartInfo(runtimePath);
if (!string.IsNullOrWhiteSpace(appRoot))
{
FileName = launcherPath,
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
UseShellExecute = false
};
startInfo.ArgumentList.Add("air-app-broker");
startInfo.ArgumentList.Add("--app-root");
startInfo.ArgumentList.Add(Path.GetFullPath(appRoot));
}
if (!string.IsNullOrWhiteSpace(dataRoot))
{
startInfo.ArgumentList.Add("--data-root");
startInfo.ArgumentList.Add(Path.GetFullPath(dataRoot));
}
startInfo.ArgumentList.Add("--requester-pid");
startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
return startInfo;
}
private static void TryStartLauncher()
private static void TryStartRuntime()
{
try
{
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
var appRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
var runtimePath = AirAppRuntimePathResolver.ResolveExecutablePath(appRoot, AppContext.BaseDirectory);
if (string.IsNullOrWhiteSpace(runtimePath) || !File.Exists(runtimePath))
{
AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found.");
AppLogger.Warn("AirAppLauncher", "Unable to start AirApp Runtime for Air APP request: runtime path was not found.");
return;
}
var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId);
var dataRoot = AirAppRuntimeDataRootResolver.ResolveDataRoot(appRoot);
var startInfo = CreateRuntimeStartInfo(runtimePath, Environment.ProcessId, appRoot, dataRoot);
_ = Process.Start(startInfo);
AppLogger.Info(
"AirAppLauncher",
$"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
$"Started AirApp Runtime. Path='{runtimePath}'; Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
}
catch (Exception ex)
{
AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex);
AppLogger.Warn("AirAppLauncher", "Failed to start AirApp Runtime for Air APP request.", ex);
}
}
}

View File

@@ -50,6 +50,11 @@ public static class AppDataPathProvider
return Path.Combine(GetDataRoot(), "Wallpapers");
}
internal static void ResetForTests()
{
_overriddenDataRoot = null;
}
private static string? ResolveDataRootFromArgs(string[] args)
{
const string prefix = "--data-root=";

View File

@@ -0,0 +1,237 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services;
internal sealed record ElevatedPluginInstallResult(
bool Success,
string? Code,
string? Message,
string? ErrorMessage,
string? InstalledPackagePath,
string? ManifestId,
string? ManifestName);
internal sealed class ElevatedPluginInstallService
{
public async Task<ElevatedPluginInstallResult> InstallAsync(
string sourcePackagePath,
string pluginsDirectory,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
if (!OperatingSystem.IsWindows())
{
return new ElevatedPluginInstallResult(
false,
"elevation_unsupported",
"Elevated plugin installation is only supported on Windows.",
"Elevated plugin installation is only supported on Windows.",
null,
null,
null);
}
var launcherPath = ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new ElevatedPluginInstallResult(
false,
"launcher_not_found",
"Launcher executable was not found for elevated plugin installation.",
$"Launcher executable was not found. ResolvedPath='{launcherPath ?? string.Empty}'.",
null,
null,
null);
}
var resultPath = Path.Combine(
Path.GetTempPath(),
$"LanMountainDesktop.PluginInstall.{Guid.NewGuid():N}.json");
try
{
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
UseShellExecute = true,
Verb = "runas",
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory
};
startInfo.ArgumentList.Add("plugin");
startInfo.ArgumentList.Add("install");
startInfo.ArgumentList.Add("--source");
startInfo.ArgumentList.Add(Path.GetFullPath(sourcePackagePath));
startInfo.ArgumentList.Add("--plugins-dir");
startInfo.ArgumentList.Add(Path.GetFullPath(pluginsDirectory));
startInfo.ArgumentList.Add("--result");
startInfo.ArgumentList.Add(resultPath);
var packageRoot = LauncherRuntimeMetadata.GetPackageRoot();
if (!string.IsNullOrWhiteSpace(packageRoot))
{
startInfo.ArgumentList.Add("--app-root");
startInfo.ArgumentList.Add(Path.GetFullPath(packageRoot));
}
var process = Process.Start(startInfo);
if (process is null)
{
return new ElevatedPluginInstallResult(
false,
"launch_failed",
"Elevated plugin installer did not start.",
"Elevated plugin installer did not start.",
null,
null,
null);
}
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (File.Exists(resultPath))
{
return ReadResult(resultPath);
}
return new ElevatedPluginInstallResult(
process.ExitCode == 0,
process.ExitCode == 0 ? "ok" : "installer_failed",
process.ExitCode == 0 ? "Plugin installed." : $"Elevated installer exited with code {process.ExitCode}.",
process.ExitCode == 0 ? null : $"Elevated installer exited with code {process.ExitCode}.",
null,
null,
null);
}
catch (OperationCanceledException)
{
throw;
}
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
return new ElevatedPluginInstallResult(
false,
"elevation_cancelled",
"Plugin installation was cancelled before elevation was approved.",
ex.Message,
null,
null,
null);
}
catch (Exception ex)
{
return new ElevatedPluginInstallResult(
false,
"elevation_failed",
"Elevated plugin installation failed.",
ex.Message,
null,
null,
null);
}
finally
{
TryDelete(resultPath);
}
}
private static ElevatedPluginInstallResult ReadResult(string resultPath)
{
try
{
using var document = JsonDocument.Parse(File.ReadAllText(resultPath));
var root = document.RootElement;
return new ElevatedPluginInstallResult(
GetBoolean(root, "Success"),
GetString(root, "Code"),
GetString(root, "Message"),
GetString(root, "ErrorMessage"),
GetString(root, "InstalledPackagePath"),
GetString(root, "ManifestId"),
GetString(root, "ManifestName"));
}
catch (Exception ex)
{
return new ElevatedPluginInstallResult(
false,
"invalid_result",
"Elevated plugin installer returned an invalid result.",
ex.Message,
null,
null,
null);
}
}
private static string? ResolveLauncherExecutablePath()
{
var candidates = new[]
{
LauncherRuntimeMetadata.GetPackageRoot(),
AppContext.BaseDirectory,
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."))
};
foreach (var root in candidates.Where(candidate => !string.IsNullOrWhiteSpace(candidate)))
{
var path = Path.Combine(root!, OperatingSystem.IsWindows()
? "LanMountainDesktop.Launcher.exe"
: "LanMountainDesktop.Launcher");
if (File.Exists(path))
{
return path;
}
}
return null;
}
private static bool GetBoolean(JsonElement element, string propertyName)
{
return TryGetProperty(element, propertyName, out var property) &&
property.ValueKind == JsonValueKind.True;
}
private static string? GetString(JsonElement element, string propertyName)
{
return TryGetProperty(element, propertyName, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement property)
{
foreach (var candidate in element.EnumerateObject())
{
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
property = candidate.Value;
return true;
}
}
property = default;
return false;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
}

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

@@ -1,9 +1,7 @@
using System;
using System.Diagnostics;
using System.IO;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -11,8 +9,6 @@ namespace LanMountainDesktop.Services;
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
public bool TryExit(HostApplicationLifecycleRequest? request = null)
{
App? app = null;
@@ -53,11 +49,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return false;
}
if (HasPendingPluginUpgrades())
{
return TryRestartWithUpgradeHelper(request);
}
return TryRestartDirectly(request);
}
catch (Exception ex)
@@ -68,61 +59,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
}
private static bool HasPendingPluginUpgrades()
{
try
{
var pluginsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Extensions",
"Plugins");
var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json");
return File.Exists(pendingUpgradesPath);
}
catch
{
return false;
}
}
private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request)
{
AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart.");
var helperPath = ResolveUpgradeHelperPath();
if (!File.Exists(helperPath))
{
AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart.");
return TryRestartDirectly(request);
}
var pluginsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Extensions",
"Plugins");
var app = Application.Current as App;
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
var launchArgs = startInfo?.Arguments ?? "";
var helperStartInfo = new ProcessStartInfo
{
FileName = helperPath,
Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"",
UseShellExecute = true,
WorkingDirectory = AppContext.BaseDirectory
};
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
Process.Start(helperStartInfo);
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
}
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
{
var app = Application.Current as App;
@@ -149,8 +85,4 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
}
private static string ResolveUpgradeHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
}
}

View File

@@ -1,90 +0,0 @@
using System;
using System.IO;
using System.Linq;
namespace LanMountainDesktop.Services;
/// <summary>
/// 统一解析 Launcher 可执行文件路径的工具类。
/// </summary>
/// <remarks>
/// 安装后的目录结构:
/// <code>
/// {AppRoot}/ ← 应用安装根目录
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
/// app-{version}/ ← Host 部署目录
/// LanMountainDesktop.exe
/// ...
/// </code>
/// </remarks>
internal static class LauncherPathResolver
{
private const string WindowsLauncherExeName = "LanMountainDesktop.Launcher.exe";
private const string UnixLauncherExeName = "LanMountainDesktop.Launcher";
private static string LauncherExecutableName =>
OperatingSystem.IsWindows() ? WindowsLauncherExeName : UnixLauncherExeName;
/// <summary>
/// 解析 Launcher 可执行文件的完整路径。如果找不到则返回 null。
/// </summary>
public static string? ResolveLauncherExecutablePath()
{
var baseDirectory = AppContext.BaseDirectory;
var candidates = new[]
{
// 1. 发布版安装版Host 在 app-* 子目录中Launcher 在父目录(应用根目录)
Path.GetFullPath(Path.Combine(baseDirectory, "..", LauncherExecutableName)),
// 2. 便携版 / 单文件发布Launcher 与 Host 在同一目录
Path.Combine(baseDirectory, LauncherExecutableName),
// 3. 开发环境Launcher 项目输出目录与 Host 项目输出目录同级
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName)),
};
return candidates
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(File.Exists);
}
/// <summary>
/// 解析 Launcher 数据目录(.Launcher的路径。
/// 该目录与 app-* 文件夹同级,位于应用安装根目录下。
/// </summary>
public static string ResolveLauncherDataDirectory()
{
var baseDirectory = AppContext.BaseDirectory;
// 优先尝试应用安装根目录Host 的父目录)
var appRootCandidate = Path.GetFullPath(Path.Combine(baseDirectory, ".."));
var launcherDataDir = Path.Combine(appRootCandidate, ".Launcher");
if (Directory.Exists(launcherDataDir) || CanWriteToDirectory(appRootCandidate))
{
return launcherDataDir;
}
// 回退到 Host 所在目录(便携模式或开发环境)
return Path.Combine(baseDirectory, ".Launcher");
}
private static bool CanWriteToDirectory(string path)
{
try
{
var testFile = Path.Combine(path, $".write-test-{Guid.NewGuid():N}.tmp");
File.WriteAllText(testFile, string.Empty);
File.Delete(testFile);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -8,6 +8,7 @@ public static class PendingRestartStateService
public const string RenderModeReason = "RenderMode";
public const string PluginCatalogReason = "PluginCatalog";
public const string SettingsWindowReason = "SettingsWindow";
public const string SystemMaterialReason = "SystemMaterial";
private static readonly object Gate = new();
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);

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

Some files were not shown because too many files have changed in this diff Show More