Compare commits

...

13 Commits

Author SHA1 Message Date
lincube
8403b89a15 fiz.4×2日历组件日期显示修复 2026-06-02 14:28:33 +08:00
lincube
0ea98c08bf feat.PLONDS客户端补全 2026-06-02 13:16:13 +08:00
lincube
54d97e312d fix.plonds-s3-multipart-upload 2026-06-02 10:09:06 +08:00
lincube
04b95020bd fix.plonds-s3-resumable-publish 2026-06-02 09:27:08 +08:00
lincube
cf08269e15 fix.plonds-s3-upload-timeout 2026-06-02 08:51:53 +08:00
lincube
03e4442e74 feat.PLONDS客户端 2026-06-01 19:48:51 +08:00
lincube
0c8830133a feat.Publisher完整包上传 2026-06-01 17:28:26 +08:00
lincube
131043fe37 changed.修改了PLONDS上传逻辑 2026-06-01 16:53:23 +08:00
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
169 changed files with 9917 additions and 3307 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: 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 cancel-in-progress: false
on: on:
@@ -19,11 +19,18 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
PLONDS_S3_PREFIX: lanmountain/update/plonds
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
PLONDS_S3_MULTIPART_THRESHOLD_MB: '8'
PLONDS_S3_MULTIPART_PART_SIZE_MB: '5'
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
jobs: jobs:
publish: publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45
permissions: permissions:
contents: write contents: write
actions: read actions: read
@@ -35,7 +42,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
- name: Resolve release tag and channel - name: Resolve release tag
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash shell: bash
@@ -53,22 +60,8 @@ jobs:
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)" TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi fi
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" 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 - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
@@ -76,304 +69,70 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview 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 - name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Download release assets - name: Download PLONDS release assets
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
mkdir -p release-assets rm -rf plonds-assets
gh release download "$RELEASE_TAG" -D release-assets mkdir -p plonds-assets
find release-assets -maxdepth 1 -type f | sort 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: 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 shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
rm -rf plonds-static if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
mkdir -p plonds-static echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
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."
exit 1 exit 1
fi fi
- name: Upload release assets to Rainyun S3 REGION="${S3_REGION:-us-east-1}"
env: PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} PUBLIC_BASE="${PUBLIC_BASE%/}"
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
- 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 -- \ dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-plonds \ publish-s3 \
--release-tag "$RELEASE_TAG" \ --release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir plonds-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \ --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 jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
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)
if [[ -z "$keys" ]]; then - name: Upload enriched PLONDS manifest to GitHub Release
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
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --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

View File

@@ -185,6 +185,29 @@ jobs:
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
shell: pwsh 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 - name: Publish AirAppHost
run: | run: |
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
@@ -215,6 +238,7 @@ jobs:
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch" $publishDir = "publish/windows-$arch"
$launcherPublishDir = "publish/launcher-win-$arch" $launcherPublishDir = "publish/launcher-win-$arch"
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
$appDir = "app-$version" $appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch" $newStructure = "publish-launcher/windows-$arch"
@@ -226,10 +250,15 @@ jobs:
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force 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 New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -Path $newStructure -Destination $publishDir -Force Move-Item -Path $newStructure -Destination $publishDir -Force
shell: pwsh shell: pwsh
@@ -253,6 +282,7 @@ jobs:
$requiredFiles = @( $requiredFiles = @(
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"), (Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
(Join-Path $appDir "LanMountainDesktop.exe"), (Join-Path $appDir "LanMountainDesktop.exe"),
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe") (Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
) )
@@ -330,7 +360,7 @@ jobs:
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}" $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)) { if (-not (Test-Path $payloadRoot)) {
Write-Error "Payload root not found: $payloadRoot" Write-Error "Payload root not found: $payloadRoot"
exit 1 exit 1
@@ -344,7 +374,7 @@ jobs:
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object { Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/') $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 return
} }
@@ -462,12 +492,32 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: 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 - name: Restructure for Launcher
run: | run: |
version="${{ needs.prepare.outputs.version }}" version="${{ needs.prepare.outputs.version }}"
publishDir="publish/linux-x64" publishDir="publish/linux-x64"
appDir="app-$version" appDir="app-$version"
launcherDir="publish/launcher-linux-x64" launcherDir="publish/launcher-linux-x64"
runtimeDir="publish/airapp-runtime-linux-x64"
mkdir -p "$publishDir" mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir" mv "publish/linux-x64-app" "$publishDir/$appDir"
@@ -477,8 +527,13 @@ jobs:
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
fi fi
if [ -d "$runtimeDir" ]; then
cp -r "$runtimeDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
fi
touch "$publishDir/$appDir/.current" touch "$publishDir/$appDir/.current"
rm -rf "$launcherDir" rm -rf "$launcherDir" "$runtimeDir"
- name: Package as DEB - name: Package as DEB
run: | run: |
@@ -637,10 +692,10 @@ jobs:
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \ -c Release \
-o ./publish/macos-${{ matrix.arch }}-app \ -o ./publish/macos-${{ matrix.arch }}-app \
--self-contained \ --self-contained:false \
-r osx-${{ matrix.arch }} \ -r osx-${{ matrix.arch }} \
-p:SelfContained=false \
-p:PublishSingleFile=false \ -p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \ -p:DebugType=none \
-p:DebugSymbols=false \ -p:DebugSymbols=false \
-p:SkipAirAppHostBuild=true \ -p:SkipAirAppHostBuild=true \
@@ -651,6 +706,36 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: 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 - name: Package Payload Zip
run: | run: |
release_dir="$PWD/release-assets" release_dir="$PWD/release-assets"
@@ -673,6 +758,7 @@ jobs:
app_name="LanMountainDesktop" app_name="LanMountainDesktop"
package_name="${app_name}-${version}-macos-${arch}" package_name="${app_name}-${version}-macos-${arch}"
launcherDir="publish/launcher-macos-$arch" launcherDir="publish/launcher-macos-$arch"
runtimeDir="publish/airapp-runtime-macos-$arch"
appSourceDir="publish/macos-$arch-app" appSourceDir="publish/macos-$arch-app"
mkdir -p "${app_name}.app/Contents/MacOS" 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 chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
fi 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" touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources" 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 # 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.Shared.IPC` builds in Debug.
- [x] `LanMountainDesktop.Launcher` builds in Debug. - [x] `LanMountainDesktop.Launcher` builds in Debug.
- [x] `LanMountainDesktop` builds in Debug. - [x] `LanMountainDesktop` builds in Debug.

View File

@@ -1,5 +1,7 @@
# Launcher Managed Air APP Lifecycle # 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 ## 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. 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 # 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 shared Air APP lifecycle IPC contracts.
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host. - [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
- [x] Make Launcher remain alive while desktop or Air APP processes exist. - [x] Make Launcher remain alive while desktop or Air APP processes exist.

View File

@@ -3,7 +3,7 @@
- [ ] New install shows OOBE once. - [ ] New install shows OOBE once.
- [ ] Same-user reinstall does not show OOBE again. - [ ] Same-user reinstall does not show OOBE again.
- [ ] `postinstall` launch path is handled without misclassifying the user state. - [ ] `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. - [ ] Default plugin install does not request UAC.
- [ ] Logs include OOBE status, suppression reason, and launch source. - [ ] 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). - [ ] 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: - `launchSource` values are treated as:
- `normal` - `normal`
- `postinstall` - `postinstall`
- `apply-update`
- `plugin-install` - `plugin-install`
- `debug-preview` - `debug-preview`
- Automatic OOBE is allowed only for normal user-mode startup. - 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. - `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: - Allowed elevation paths are limited to:
- the installer itself - the installer itself
- full installer update application - 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 保留两个工作流 ### 6.1 保留两个工作流
- **Comparator**`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json` - **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 改造后步骤 ### 6.2 Comparator 改造后步骤
@@ -297,7 +298,43 @@ jobs:
→ 上传 artifact: plonds-run-metadata (tag.txt) → 上传 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 | | 构建增量资产 (pwsh含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json | | 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
| 传递元数据 | ✅ 保留,但 artifact 内容简化 | | 传递元数据 | ✅ 保留,但 artifact 内容简化 |
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
| 独立 rollback workflow | ❌ 删除 |
## 7. 双模式差分生成 ## 7. 双模式差分生成
@@ -504,9 +543,7 @@ build-delta-from-commits --platform <platform>
## 8. 不在本次改造范围内的事项 ## 8. 不在本次改造范围内的事项
- Publisher 工作流改造(后续单独设计)
- Rollback 工作流改造(后续单独设计)
- 宿主侧客户端代码改造PlondsUpdateApplier 等,后续单独设计) - 宿主侧客户端代码改造PlondsUpdateApplier 等,后续单独设计)
- Launcher 侧客户端代码改造(后续单独设计) - Launcher 侧客户端代码改造(后续单独设计)
- Plonds.Api 项目处置(后续决定是否保留) - 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. - 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. - 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` 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. - 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 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. - 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. - 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 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. - 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 ## 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). - 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. - 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 ## 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 internal sealed class AirAppHostLocator
{ {
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe"; private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
private const string DllName = "LanMountainDesktop.AirAppHost.dll"; private const string DllName = "LanMountainDesktop.AirAppHost.dll";
private static string ExecutableName => OperatingSystem.IsWindows()
? WindowsExecutableName
: UnixExecutableName;
public string Resolve(string? packageRoot, string? hostPath = null) public string Resolve(string? packageRoot, string? hostPath = null)
{ {
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath)) foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
{ {
foreach (var root in EnumerateRoots(packageRoot, hostPath)) 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, "AirAppHost", DllName);
yield return Path.Combine(root, WindowsExecutableName); yield return Path.Combine(root, ExecutableName);
yield return Path.Combine(root, DllName); yield return Path.Combine(root, DllName);
if (Directory.Exists(root)) if (Directory.Exists(root))
{ {
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)) 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, "AirAppHost", DllName);
yield return Path.Combine(deploymentDirectory, WindowsExecutableName); yield return Path.Combine(deploymentDirectory, ExecutableName);
yield return Path.Combine(deploymentDirectory, DllName); yield return Path.Combine(deploymentDirectory, DllName);
} }
} }
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
"Release", "Release",
#endif #endif
"net10.0", "net10.0",
WindowsExecutableName); ExecutableName);
yield return Path.Combine( yield return Path.Combine(
current.FullName, current.FullName,

View File

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

View File

@@ -2,15 +2,15 @@ using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using LanMountainDesktop.Shared.IPC.Abstractions.Services; 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 object _gate = new();
private readonly IAirAppProcessStarter _processStarter; private readonly IAirAppProcessStarter _processStarter;
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter) public AirAppLifecycleService(IAirAppProcessStarter processStarter)
{ {
_processStarter = processStarter; _processStarter = processStarter;
} }
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
var appId = Normalize(request.AppId, "unknown"); var appId = Normalize(request.AppId, "unknown");
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId); var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
Logger.Info( AirAppRuntimeLogger.Info(
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}."); $"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
lock (_gate) lock (_gate)
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
request.SourceComponentId, request.SourceComponentId,
request.SourcePlacementId); request.SourcePlacementId);
_instances[instanceKey] = instance; _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)); return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
} }
catch (Exception ex) 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)); return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
} }
} }
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
request.SourceComponentId, request.SourceComponentId,
request.SourcePlacementId); request.SourcePlacementId);
_instances[instanceKey] = instance; _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)); 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)) (processId <= 0 || instance.ProcessId == processId))
{ {
_instances.Remove(instanceKey); _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)); 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) foreach (var key in exitedKeys)
{ {
_instances.Remove(key); _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) if (processId <= 0)
{ {
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
private static string Normalize(string? value, string fallback) private static string Normalize(string? value, string fallback)
{ {
return string.IsNullOrWhiteSpace(value) return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
? fallback
: value.Trim();
} }
private const int SW_SHOWNORMAL = 1; 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; using System.Diagnostics;
namespace LanMountainDesktop.Launcher.AirApp; using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.AirAppRuntime;
internal interface IAirAppProcessStarter internal interface IAirAppProcessStarter
{ {
@@ -12,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
private readonly Func<string?> _packageRootProvider; private readonly Func<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider; private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider; private readonly Func<string?> _dataRootProvider;
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
public AirAppProcessStarter( public AirAppProcessStarter(
AirAppHostLocator locator, AirAppHostLocator locator,
Func<string?> packageRootProvider, Func<string?> packageRootProvider,
Func<string?> hostPathProvider, Func<string?> hostPathProvider,
Func<string?> dataRootProvider, Func<string?> dataRootProvider)
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
{ {
_locator = locator; _locator = locator;
_packageRootProvider = packageRootProvider; _packageRootProvider = packageRootProvider;
_hostPathProvider = hostPathProvider; _hostPathProvider = hostPathProvider;
_dataRootProvider = dataRootProvider; _dataRootProvider = dataRootProvider;
_runtimeProbeOptions = runtimeProbeOptions;
} }
public Process? Start( public Process? Start(
@@ -36,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
string? sourcePlacementId) string? sourcePlacementId)
{ {
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider()); var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions); var startInfo = CreateStartInfo(hostPath);
AddArgument(startInfo, "--app-id", appId); AddArgument(startInfo, "--app-id", appId);
AddArgument(startInfo, "--session-id", sessionId); AddArgument(startInfo, "--session-id", sessionId);
AddArgument(startInfo, "--instance-key", instanceKey); AddArgument(startInfo, "--instance-key", instanceKey);
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName); AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
var dataRoot = _dataRootProvider(); var dataRoot = _dataRootProvider();
if (!string.IsNullOrWhiteSpace(dataRoot)) if (!string.IsNullOrWhiteSpace(dataRoot))
{ {
@@ -58,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim()); AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
} }
Logger.Info( AirAppRuntimeLogger.Info(
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'."); $"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
var process = Process.Start(startInfo); var process = Process.Start(startInfo);
if (process is not null) if (process is not null)
@@ -68,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
{ {
try try
{ {
Logger.Info( AirAppRuntimeLogger.Info(
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}."); $"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
} }
catch (Exception ex) 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; return process;
} }
internal static ProcessStartInfo CreateStartInfo( internal static ProcessStartInfo CreateStartInfo(string hostPath)
string hostPath,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
{ {
var startInfo = new ProcessStartInfo return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
{
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;
} }
private static void AddArgument(ProcessStartInfo startInfo, string name, string value) private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{ {
startInfo.ArgumentList.Add(name); 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; return;
} }
if (context.IsAirAppBrokerCommand)
{
_ = AirAppBrokerEntryHandler.RunAsync(desktop, context);
base.OnFrameworkInitializationCompleted();
return;
}
if (context.IsDebugMode && !context.IsPreviewCommand) if (context.IsDebugMode && !context.IsPreviewCommand)
{ {
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow."); Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");

View File

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

View File

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

View File

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

View File

@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false) public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{ {
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath) var targetDataRoot = mode == DataLocationMode.Portable
? Path.GetFullPath(customPath) ? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath; : _defaultSystemDataPath;
var config = new DataLocationConfig 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"> <Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup> <PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource> <PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir> <PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
</PropertyGroup> </PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" /> <MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" /> <Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
@@ -55,7 +55,7 @@
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish"> <Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup> <PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource> <PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir> <PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
</PropertyGroup> </PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" /> <MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" /> <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; 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 internal sealed class SnapshotMetadata
{ {
public string SnapshotId { get; set; } = string.Empty; public string SnapshotId { get; set; } = string.Empty;
@@ -40,124 +16,3 @@ internal sealed class SnapshotMetadata
public string Status { get; set; } = "pending"; 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) 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 fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory); var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath); throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
} }
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult) if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
{ {
return 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()) if (!OperatingSystem.IsWindows())
{ {
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
string? allowedRoot = null; string? allowedRoot = null;
try try
{ {
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
var resolver = new DataLocationResolver(appRoot); ? Path.GetFullPath(appRoot)
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(resolvedAppRoot);
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot()); allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
} }
catch catch

View File

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

View File

@@ -57,14 +57,6 @@
"DOTNET_ENVIRONMENT": "Development" "DOTNET_ENVIRONMENT": "Development"
} }
}, },
"Launcher (Update Check)": {
"commandName": "Project",
"commandLineArgs": "update check",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Plugin Install)": { "Launcher (Plugin Install)": {
"commandName": "Project", "commandName": "Project",
"commandLineArgs": "plugin install <path-to-plugin.laapp>", "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.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers; namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
@@ -30,52 +28,3 @@ internal static class LaunchEntryHandler
SplashWindow splashWindow) => SplashWindow splashWindow) =>
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, 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 startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context); var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
if (!startupAttemptRegistry.TryReserveCoordinator( if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource, context.LaunchSource,
successPolicy, successPolicy,
@@ -44,15 +47,6 @@ internal static class LauncherGuiCoordinator
return; return;
} }
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
new LauncherAirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => appRoot,
() => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start();
using var coordinatorServer = new LauncherCoordinatorIpcServer( using var coordinatorServer = new LauncherCoordinatorIpcServer(
coordinatorPipeName, coordinatorPipeName,
BuildCoordinatorStatusFromAttempt(reservedAttempt), BuildCoordinatorStatusFromAttempt(reservedAttempt),
@@ -129,7 +123,8 @@ internal static class LauncherGuiCoordinator
if (result.Success) if (result.Success)
{ {
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); 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); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
@@ -173,17 +168,15 @@ internal static class LauncherGuiCoordinator
return fallbackHostPid; return fallbackHostPid;
} }
private static async Task WaitForManagedProcessesToExitAsync( private static async Task WaitForHostProcessToExitAsync(int hostPid)
int hostPid,
LauncherAirAppLifecycleService airAppLifecycleService)
{ {
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}."); Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps()) while (TryGetLiveProcess(hostPid))
{ {
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); 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( 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 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"; public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";

View File

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

View File

@@ -1,5 +1,3 @@
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Infrastructure;
using Xunit; using Xunit;
namespace LanMountainDesktop.Tests; namespace LanMountainDesktop.Tests;
@@ -29,38 +27,14 @@ public sealed class AirAppProcessStarterRuntimeTests : IDisposable
} }
[Fact] [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"); var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
File.WriteAllText(hostDll, string.Empty); 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()); 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 System.Diagnostics;
using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
using LanMountainDesktop.Shared.IPC.Abstractions.Services; using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Xunit; using Xunit;
namespace LanMountainDesktop.Tests; namespace LanMountainDesktop.Tests;
public sealed class LauncherAirAppLifecycleServiceTests public sealed class AirAppRuntimeLifecycleServiceTests
{ {
[Fact] [Fact]
public async Task OpenAsync_ReusesExistingInstanceForSameKey() public async Task OpenAsync_ReusesExistingInstanceForSameKey()
{ {
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess()); var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter); var service = new AirAppLifecycleService(starter);
var request = new AirAppOpenRequest( var request = new AirAppOpenRequest(
"whiteboard", "whiteboard",
BuiltInComponentIds.DesktopWhiteboard, BuiltInComponentIds.DesktopWhiteboard,
@@ -36,7 +33,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents() public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
{ {
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess()); var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter); var service = new AirAppLifecycleService(starter);
var first = await service.OpenAsync(new AirAppOpenRequest( var first = await service.OpenAsync(new AirAppOpenRequest(
"world-clock", "world-clock",
@@ -62,7 +59,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart() public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
{ {
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess()); var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter); var service = new AirAppLifecycleService(starter);
var instanceKey = AirAppInstanceKey.Build( var instanceKey = AirAppInstanceKey.Build(
"whiteboard", "whiteboard",
BuiltInComponentIds.DesktopWhiteboard, BuiltInComponentIds.DesktopWhiteboard,
@@ -92,7 +89,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
[Fact] [Fact]
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance() 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"); var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
_ = await service.RegisterAsync(new AirAppRegistrationRequest( _ = await service.RegisterAsync(new AirAppRegistrationRequest(
@@ -112,26 +109,35 @@ public sealed class LauncherAirAppLifecycleServiceTests
} }
[Fact] [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] [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] [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 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( _ = await service.RegisterAsync(new AirAppRegistrationRequest(
instanceKey, instanceKey,
@@ -142,28 +148,23 @@ public sealed class LauncherAirAppLifecycleServiceTests
BuiltInComponentIds.DesktopWorldClock, BuiltInComponentIds.DesktopWorldClock,
"clock-2")); "clock-2"));
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service)); Assert.True(lifetime.ShouldKeepAlive());
} }
[Fact] [Fact]
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment() public async Task RuntimeControl_AttachesHostProcess()
{ {
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
try var lifetime = new AirAppRuntimeLifetime(
{ new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); 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(result.Accepted);
Assert.True(context.IsAirAppBrokerCommand); Assert.Equal(Environment.ProcessId, result.Status.HostProcessId);
Assert.True(context.IsDebugMode); Assert.True(result.Status.HostProcessAlive);
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
}
} }
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter private sealed class TestAirAppProcessStarter : IAirAppProcessStarter

View File

@@ -9,7 +9,6 @@ public sealed class CommandContextTests
{ {
{ [], "normal" }, { [], "normal" },
{ ["preview-oobe"], "debug-preview" }, { ["preview-oobe"], "debug-preview" },
{ ["apply-update"], "normal" },
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" }, { ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
{ ["launch", "--launch-source", "postinstall"], "postinstall" } { ["launch", "--launch-source", "postinstall"], "postinstall" }
}; };
@@ -22,4 +21,12 @@ public sealed class CommandContextTests
Assert.Equal(expectedLaunchSource, context.LaunchSource); 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

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

View File

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

View File

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

View File

@@ -47,6 +47,95 @@ public sealed class LauncherArchitectureTests
Assert.Empty(offenders); 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] [Fact]
public void LauncherCompositionRootStaysThin() 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.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure; global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc; 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"); var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
Assert.Contains("Publish-LauncherPayload", script); Assert.Contains("Publish-LauncherPayload", script);
Assert.Contains("Publish-AirAppRuntimePayload", script);
Assert.Contains("\"app-$Version\"", script); Assert.Contains("\"app-$Version\"", script);
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script); Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
Assert.Contains("\"--self-contained\", \"false\"", script); Assert.Contains("\"--self-contained\", \"false\"", script);
Assert.Contains("\"-p:SelfContained=false\"", script); Assert.Contains("\"-p:SelfContained=false\"", script);
Assert.Contains("\"-p:PublishAot=false\"", script);
} }
[Fact] [Fact]
@@ -28,12 +30,13 @@ public sealed class PackagingRuntimePolicyTests
} }
[Fact] [Fact]
public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost() public void WindowsPayloadGuard_RequiresLauncherRuntimeMainAndAirAppHost()
{ {
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1"); var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script); Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
Assert.Contains("LanMountainDesktop.Launcher.exe", script); Assert.Contains("LanMountainDesktop.Launcher.exe", script);
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", script);
Assert.Contains("LanMountainDesktop.exe", script); Assert.Contains("LanMountainDesktop.exe", script);
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script); Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
} }
@@ -44,9 +47,21 @@ public sealed class PackagingRuntimePolicyTests
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml"); var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
Assert.Contains("Verify Windows app host payload", workflow); Assert.Contains("Verify Windows app host payload", workflow);
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", workflow);
Assert.Contains("LanMountainDesktop.AirAppHost.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] [Fact]
public void Installer_DownloadsArchitectureSpecificDesktopRuntime() 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 LanMountainDesktop.Launcher.Plugins;
using System.IO.Compression; using System.IO.Compression;
using System.Text.Json;
using Xunit; using Xunit;
namespace LanMountainDesktop.Tests; namespace LanMountainDesktop.Tests;
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
Directory.CreateDirectory(_tempRoot); Directory.CreateDirectory(_tempRoot);
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin"); CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
var pluginsDirectory = CreateUserScopedPluginsDirectory(); var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService(); var service = new PluginInstallerService();
var result = service.InstallPackage(packagePath, pluginsDirectory); var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
Assert.True(result.Success); Assert.True(result.Success);
Assert.Equal("ok", result.Code); Assert.Equal("ok", result.Code);
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories)); 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] [Fact]
public void InstallPackage_ReplacesExistingPackageWithSamePluginId() public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
{ {
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1"); CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2"); CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
var pluginsDirectory = CreateUserScopedPluginsDirectory(); var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService(); var service = new PluginInstallerService();
var first = service.InstallPackage(firstPackagePath, pluginsDirectory); var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
var second = service.InstallPackage(secondPackagePath, pluginsDirectory); var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
Assert.True(first.Success); Assert.True(first.Success);
Assert.True(second.Success); Assert.True(second.Success);
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
Directory.CreateDirectory(_tempRoot); Directory.CreateDirectory(_tempRoot);
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin"); CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
var pluginsDirectory = CreateUserScopedPluginsDirectory(); var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService(); var service = new PluginInstallerService();
var result = service.InstallPackage(packagePath, pluginsDirectory); var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
Assert.True(result.Success); Assert.True(result.Success);
Assert.Equal("plugin.legacy.sample", result.ManifestId); 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( appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), var portableDataRoot = Path.Combine(appRoot, "Desktop");
"LanMountainDesktop", var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
"Tests", Directory.CreateDirectory(launcherDataRoot);
nameof(PluginInstallerServiceTests), File.WriteAllText(
Guid.NewGuid().ToString("N"), Path.Combine(launcherDataRoot, "data-location.config.json"),
"Extensions", JsonSerializer.Serialize(new
"Plugins"); {
Directory.CreateDirectory(root); DataLocationMode = "Portable",
return root; SystemDataPath = Path.Combine(_tempRoot, "System"),
PortableDataPath = portableDataRoot
}));
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
Directory.CreateDirectory(pluginsDirectory);
return pluginsDirectory;
} }
public void Dispose() 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.Models;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Plonds;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Services.Update; using LanMountainDesktop.Services.Update;
using LanMountainDesktop.Shared.Contracts.Update; using LanMountainDesktop.Shared.Contracts.Update;
@@ -103,35 +104,111 @@ public sealed class UpdateSettingsInterfaceTests
} }
[Fact] [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 plonds = new FakePlondsService
var github = new FakeManifestProvider("github"); {
var provider = new SettingsUpdateManifestProvider(new FakeSettingsFacade(update), plonds, github); 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( var report = await service.CheckAsync(CancellationToken.None);
UpdateSettingsValues.ChannelStable,
"windows-x64",
new Version(1, 0, 0),
CancellationToken.None);
Assert.Equal("github", manifest?.DistributionId); Assert.True(report.IsUpdateAvailable);
Assert.Equal(0, plonds.GetLatestCalls); Assert.Equal("9.9.9", report.LatestVersion);
Assert.Equal(1, github.GetLatestCalls); Assert.Equal(1, plonds.FindLatestCalls);
Assert.False(orchestratorCreated);
}
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds }; [Fact]
manifest = await provider.GetLatestAsync( public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
UpdateSettingsValues.ChannelStable, {
"windows-x64", var settings = new FakeSettingsService
new Version(1, 0, 0), {
CancellationToken.None); 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); var report = await service.CheckAsync(CancellationToken.None);
Assert.Equal(1, plonds.GetLatestCalls);
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] [Fact]
@@ -177,6 +254,33 @@ public sealed class UpdateSettingsInterfaceTests
LastUpdateCheckUtcMs: null, LastUpdateCheckUtcMs: null,
PendingUpdateSha256: 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 private sealed class FakeUpdateSettingsService : IUpdateSettingsService
{ {
public SettingsUpdateState State { get; set; } = DefaultUpdateState(); 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) public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); => 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( public Task<LanMountainDesktop.Services.UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset, GitHubReleaseAsset asset,
string destinationFilePath, string destinationFilePath,
@@ -285,6 +386,115 @@ public sealed class UpdateSettingsInterfaceTests
=> Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false)); => 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 private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider
{ {
public string ProviderName { get; } = providerName; public string ProviderName { get; } = providerName;
@@ -318,6 +528,14 @@ public sealed class UpdateSettingsInterfaceTests
new Dictionary<string, string>()); 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 private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService
{ {
public ISettingsService Settings => throw new NotSupportedException(); public ISettingsService Settings => throw new NotSupportedException();

View File

@@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests
{ {
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs"); var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.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"); var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
Assert.Contains("DataRoot", optionsSource); Assert.Contains("DataRoot", optionsSource);

View File

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

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.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.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.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.follow_system": "Follow system color scheme",
"component.color_scheme.native": "Use component custom color scheme", "component.color_scheme.native": "Use component custom color scheme",
"component.settings.color_scheme": "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.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.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.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.primary": "Primary",
"settings.appearance.preview.secondary": "Secondary", "settings.appearance.preview.secondary": "Secondary",
"settings.appearance.preview.tertiary": "Tertiary", "settings.appearance.preview.tertiary": "Tertiary",
@@ -442,6 +440,7 @@
"settings.material_color.wallpaper_seed.label": "Seed", "settings.material_color.wallpaper_seed.label": "Seed",
"settings.material_color.system_material.label": "System material", "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.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.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.description": "Use OS wallpaper notifications first and keep polling as fallback.",
"settings.material_color.native_events.active": "Native wallpaper events active", "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.app": "当前正在预览从应用壁纸提取的颜色。",
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。", "settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。", "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.follow_system": "跟随系统配色",
"component.color_scheme.native": "使用组件自定义配色", "component.color_scheme.native": "使用组件自定义配色",
"component.settings.color_scheme": "配色方案", "component.settings.color_scheme": "配色方案",
@@ -406,7 +404,7 @@
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。", "settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。", "settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
"settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica在 Windows 10 优先使用 Acrylic不可用时回退到无材质。", "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.primary": "主色",
"settings.appearance.preview.secondary": "次色", "settings.appearance.preview.secondary": "次色",
"settings.appearance.preview.tertiary": "三次色", "settings.appearance.preview.tertiary": "三次色",
@@ -442,6 +440,7 @@
"settings.material_color.wallpaper_seed.label": "种子色", "settings.material_color.wallpaper_seed.label": "种子色",
"settings.material_color.system_material.label": "系统材质", "settings.material_color.system_material.label": "系统材质",
"settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。", "settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。",
"settings.material_color.system_material.restart_message": "系统材质更改需要重启应用。",
"settings.material_color.native_events.label": "原生壁纸变更事件", "settings.material_color.native_events.label": "原生壁纸变更事件",
"settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。", "settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。",
"settings.material_color.native_events.active": "原生壁纸事件已激活", "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 WorldClockAppId = "world-clock";
public const string WhiteboardAppId = "whiteboard"; public const string WhiteboardAppId = "whiteboard";
private const int LauncherIpcRetryCount = 4; private const int RuntimeIpcRetryCount = 4;
public void OpenWorldClock(string? sourcePlacementId) public void OpenWorldClock(string? sourcePlacementId)
{ {
@@ -82,27 +82,27 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
var result = await SendOpenRequestAsync(request).ConfigureAwait(false); var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
if (result.Accepted) 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; 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) 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) private static async Task<AirAppOperationResult> SendOpenRequestAsync(AirAppOpenRequest request)
{ {
Exception? lastException = null; Exception? lastException = null;
for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++) for (var attempt = 1; attempt <= RuntimeIpcRetryCount; attempt++)
{ {
try try
{ {
using var client = new LanMountainDesktopIpcClient(); using var client = new LanMountainDesktopIpcClient();
await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false); await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
var proxy = client.CreateProxy<IAirAppLifecycleService>(); var proxy = client.CreateProxy<IAirAppLifecycleService>();
return await proxy.OpenAsync(request).ConfigureAwait(false); return await proxy.OpenAsync(request).ConfigureAwait(false);
} }
@@ -113,9 +113,9 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
{ {
AppLogger.Warn( AppLogger.Warn(
"AirAppLauncher", "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); ex);
TryStartLauncher(); TryStartRuntime();
} }
await Task.Delay(250 * attempt).ConfigureAwait(false); await Task.Delay(250 * attempt).ConfigureAwait(false);
@@ -123,44 +123,52 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
} }
throw new InvalidOperationException( throw new InvalidOperationException(
$"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.", $"AirApp Runtime IPC is unavailable. Pipe='{IpcConstants.AirAppRuntimePipeName}'.",
lastException); 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, startInfo.ArgumentList.Add("--app-root");
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory, startInfo.ArgumentList.Add(Path.GetFullPath(appRoot));
UseShellExecute = false }
};
startInfo.ArgumentList.Add("air-app-broker"); if (!string.IsNullOrWhiteSpace(dataRoot))
{
startInfo.ArgumentList.Add("--data-root");
startInfo.ArgumentList.Add(Path.GetFullPath(dataRoot));
}
startInfo.ArgumentList.Add("--requester-pid"); startInfo.ArgumentList.Add("--requester-pid");
startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
return startInfo; return startInfo;
} }
private static void TryStartLauncher() private static void TryStartRuntime()
{ {
try try
{ {
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath(); var appRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath)) 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; return;
} }
var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId); var dataRoot = AirAppRuntimeDataRootResolver.ResolveDataRoot(appRoot);
var startInfo = CreateRuntimeStartInfo(runtimePath, Environment.ProcessId, appRoot, dataRoot);
_ = Process.Start(startInfo); _ = Process.Start(startInfo);
AppLogger.Info( AppLogger.Info(
"AirAppLauncher", "AirAppLauncher",
$"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'."); $"Started AirApp Runtime. Path='{runtimePath}'; Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
} }
catch (Exception ex) 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"); return Path.Combine(GetDataRoot(), "Wallpapers");
} }
internal static void ResetForTests()
{
_overriddenDataRoot = null;
}
private static string? ResolveDataRootFromArgs(string[] args) private static string? ResolveDataRootFromArgs(string[] args)
{ {
const string prefix = "--data-root="; 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, GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset, GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage, string? ErrorMessage,
bool ForceMode = false, bool ForceMode = false);
PlondsUpdatePayload? PlondsPayload = null);
public sealed record PlondsUpdatePayload(
string DistributionId,
string ChannelId,
string SubChannel,
string? FileMapJson,
string? FileMapSignature,
string? FileMapJsonUrl,
string? FileMapSignatureUrl,
string? UpdateArchiveUrl = null,
string? UpdateArchiveSha256 = null,
long? UpdateArchiveSizeBytes = null);
public sealed record UpdateDownloadResult( public sealed record UpdateDownloadResult(
bool Success, bool Success,
@@ -162,10 +149,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var preferredAsset = isUpdateAvailable var preferredAsset = isUpdateAvailable
? SelectPreferredInstallerAsset(release.Assets) ? SelectPreferredInstallerAsset(release.Assets)
: null; : null;
var plondsPayload = isUpdateAvailable
? TryResolvePlondsPayload(release)
: null;
return new UpdateCheckResult( return new UpdateCheckResult(
Success: true, Success: true,
IsUpdateAvailable: isUpdateAvailable, IsUpdateAvailable: isUpdateAvailable,
@@ -173,8 +156,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText, LatestVersionText: latestVersionText,
Release: release, Release: release,
PreferredAsset: preferredAsset, PreferredAsset: preferredAsset,
ErrorMessage: null, ErrorMessage: null);
PlondsPayload: plondsPayload);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -239,8 +221,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
: release.TagName; : release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets); var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
var plondsPayload = TryResolvePlondsPayload(release);
return new UpdateCheckResult( return new UpdateCheckResult(
Success: true, Success: true,
IsUpdateAvailable: true, IsUpdateAvailable: true,
@@ -249,8 +229,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
Release: release, Release: release,
PreferredAsset: preferredAsset, PreferredAsset: preferredAsset,
ErrorMessage: null, ErrorMessage: null,
ForceMode: true, ForceMode: true);
PlondsPayload: plondsPayload);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -703,46 +682,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return null; 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() private static string GetPlatformAssetSuffix()
{ {
var os = OperatingSystem.IsWindows() var os = OperatingSystem.IsWindows()

View File

@@ -1,9 +1,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -11,8 +9,6 @@ namespace LanMountainDesktop.Services;
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{ {
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
public bool TryExit(HostApplicationLifecycleRequest? request = null) public bool TryExit(HostApplicationLifecycleRequest? request = null)
{ {
App? app = null; App? app = null;
@@ -53,11 +49,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return false; return false;
} }
if (HasPendingPluginUpgrades())
{
return TryRestartWithUpgradeHelper(request);
}
return TryRestartDirectly(request); return TryRestartDirectly(request);
} }
catch (Exception ex) 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) private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
{ {
var app = Application.Current as App; var app = Application.Current as App;
@@ -149,8 +85,4 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true; 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 RenderModeReason = "RenderMode";
public const string PluginCatalogReason = "PluginCatalog"; public const string PluginCatalogReason = "PluginCatalog";
public const string SettingsWindowReason = "SettingsWindow"; public const string SettingsWindowReason = "SettingsWindow";
public const string SystemMaterialReason = "SystemMaterial";
private static readonly object Gate = new(); private static readonly object Gate = new();
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase); 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);
}
}

View File

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

View File

@@ -0,0 +1,155 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanMountainDesktop.Services.Plonds;
internal sealed class PlondsPackageStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly string _rootDirectory;
public PlondsPackageStore(string rootDirectory)
{
if (string.IsNullOrWhiteSpace(rootDirectory))
{
throw new ArgumentException("PLONDS package store root is required.", nameof(rootDirectory));
}
_rootDirectory = Path.GetFullPath(rootDirectory);
Directory.CreateDirectory(_rootDirectory);
}
public async Task<PlondsPackageStaging> CreateStagingAsync(
PlondsClientManifest manifest,
PlondsSourceDescriptor source,
PlondsPackageMode mode,
CancellationToken cancellationToken)
{
if (!PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version))
{
throw new InvalidDataException($"Invalid PLONDS version: {manifest.CurrentVersion}");
}
var modeDirectoryName = mode is PlondsPackageMode.Delta ? "delta" : "full";
var stagingRoot = Path.Combine(
_rootDirectory,
SanitizePathSegment(version.ToString()),
SanitizePathSegment(source.Id),
modeDirectoryName);
EnsureCleanDirectory(stagingRoot);
var manifestPath = Path.Combine(stagingRoot, "PLONDS.json");
await using (var manifestStream = File.Create(manifestPath))
{
await JsonSerializer.SerializeAsync(manifestStream, manifest, JsonOptions, cancellationToken).ConfigureAwait(false);
}
var zipPath = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed.zip" : "Files.zip");
var extractDirectory = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed" : "Files");
Directory.CreateDirectory(extractDirectory);
return new PlondsPackageStaging(version, mode, stagingRoot, manifestPath, zipPath, extractDirectory);
}
public void ExtractPackage(string zipPath, string destinationDirectory)
{
var resolvedDestination = Path.GetFullPath(destinationDirectory);
EnsureStorePath(resolvedDestination);
EnsureCleanDirectory(resolvedDestination);
using var archive = ZipFile.OpenRead(zipPath);
foreach (var entry in archive.Entries)
{
var destinationPath = Path.GetFullPath(Path.Combine(resolvedDestination, entry.FullName));
EnsureChildPath(resolvedDestination, destinationPath);
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
var directory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
entry.ExtractToFile(destinationPath, overwrite: true);
}
}
private void EnsureCleanDirectory(string path)
{
var resolvedPath = Path.GetFullPath(path);
EnsureStorePath(resolvedPath);
if (Directory.Exists(resolvedPath))
{
Directory.Delete(resolvedPath, recursive: true);
}
Directory.CreateDirectory(resolvedPath);
}
private void EnsureStorePath(string path)
{
if (!IsSameOrChildPath(_rootDirectory, path))
{
throw new InvalidOperationException($"PLONDS staging path is outside the package store: {path}");
}
}
private static void EnsureChildPath(string parent, string child)
{
if (!IsSameOrChildPath(parent, child))
{
throw new InvalidDataException($"PLONDS package entry escapes the staging directory: {child}");
}
}
private static bool IsSameOrChildPath(string parent, string child)
{
var resolvedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var resolvedChild = Path.GetFullPath(child);
return string.Equals(resolvedParent, resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
private static string SanitizePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
var sanitized = new string(chars).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized;
}
}
internal sealed record PlondsPackageStaging(
Version Version,
PlondsPackageMode Mode,
string RootDirectory,
string ManifestPath,
string PackageZipPath,
string ExtractDirectory)
{
public PlondsPreparedPackage ToPreparedPackage()
{
return new PlondsPreparedPackage(
Version,
Mode,
ManifestPath,
Mode is PlondsPackageMode.Delta ? PackageZipPath : null,
Mode is PlondsPackageMode.Delta ? ExtractDirectory : null,
Mode is PlondsPackageMode.Full ? PackageZipPath : null,
Mode is PlondsPackageMode.Full ? ExtractDirectory : null);
}
}

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsPrepareResult(
bool Success,
PlondsPreparedPackage? Package,
string? ErrorMessage,
bool RequiresUiHandling)
{
public static PlondsPrepareResult Prepared(PlondsPreparedPackage package) => new(true, package, null, false);
public static PlondsPrepareResult FailedForUi(string message) => new(false, null, message, true);
}

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsPreparedPackage(
Version Version,
PlondsPackageMode Mode,
string ManifestPath,
string? ChangedZipPath,
string? ChangedDirectory,
string? FilesZipPath,
string? FilesDirectory);

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