mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8403b89a15 | ||
|
|
0ea98c08bf | ||
|
|
54d97e312d | ||
|
|
04b95020bd | ||
|
|
cf08269e15 | ||
|
|
03e4442e74 | ||
|
|
0c8830133a | ||
|
|
131043fe37 | ||
|
|
a2ac302ee7 | ||
|
|
c351a8e7f3 | ||
|
|
21e970c5b6 | ||
|
|
17873f0f43 | ||
|
|
4051b5cd74 |
146
.github/workflows/plonds-rollback.yml
vendored
146
.github/workflows/plonds-rollback.yml
vendored
@@ -1,146 +0,0 @@
|
||||
name: PLONDS Rollback
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: 'Target channel to rollback'
|
||||
required: true
|
||||
type: choice
|
||||
default: stable
|
||||
options:
|
||||
- stable
|
||||
- preview
|
||||
target_tag:
|
||||
description: 'Release tag to rollback to (e.g. v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: plonds-rollback-${{ github.event.inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve rollback context
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
RAW_TAG="${{ github.event.inputs.target_tag }}"
|
||||
if [[ "$RAW_TAG" == v* ]]; then
|
||||
TAG="$RAW_TAG"
|
||||
else
|
||||
TAG="v$RAW_TAG"
|
||||
fi
|
||||
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate rollback target assets
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
for name in plonds.json plonds.json.sig; do
|
||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
done
|
||||
|
||||
- name: Build rollback pointer
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p rollback-output
|
||||
pointer_file="rollback-output/plonds-latest.json"
|
||||
|
||||
manifest_url="${S3_BASE_URL}/plonds.json"
|
||||
sig_url="${S3_BASE_URL}/plonds.json.sig"
|
||||
version="${RELEASE_TAG#v}"
|
||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
cat > "$pointer_file" <<EOF
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"channel": "${RELEASE_CHANNEL}",
|
||||
"releaseTag": "${RELEASE_TAG}",
|
||||
"version": "${version}",
|
||||
"updatedAt": "${updated_at}",
|
||||
"manifest": {
|
||||
"url": "${manifest_url}",
|
||||
"signatureUrl": "${sig_url}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
jq -e . "$pointer_file" >/dev/null
|
||||
|
||||
- name: Publish rollback pointer
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pointer_file="rollback-output/plonds-latest.json"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
|
||||
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
|
||||
|
||||
- name: Print rollback summary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
|
||||
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json"
|
||||
343
.github/workflows/plonds-uploader.yml
vendored
343
.github/workflows/plonds-uploader.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: PLONDS Publisher
|
||||
name: PLONDS Publisher
|
||||
|
||||
concurrency:
|
||||
group: plonds-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||
group: plonds-publish-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
@@ -19,11 +19,18 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
PLONDS_S3_PREFIX: lanmountain/update/plonds
|
||||
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
|
||||
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
|
||||
PLONDS_S3_MULTIPART_THRESHOLD_MB: '8'
|
||||
PLONDS_S3_MULTIPART_PART_SIZE_MB: '5'
|
||||
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
@@ -35,7 +42,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve release tag and channel
|
||||
- name: Resolve release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
@@ -53,22 +60,8 @@ jobs:
|
||||
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
||||
fi
|
||||
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
CHANNEL="preview"
|
||||
else
|
||||
CHANNEL="stable"
|
||||
fi
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -76,304 +69,70 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: preview
|
||||
|
||||
- name: Prepare signing key
|
||||
env:
|
||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
||||
if [[ -z "$KEY" ]]; then
|
||||
echo "No signing key is configured."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$KEY" > update-private-key.pem
|
||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PLONDS tool
|
||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||
|
||||
- name: Download release assets
|
||||
- name: Download PLONDS release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release-assets
|
||||
gh release download "$RELEASE_TAG" -D release-assets
|
||||
find release-assets -maxdepth 1 -type f | sort
|
||||
rm -rf plonds-assets
|
||||
mkdir -p plonds-assets
|
||||
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -p files-windows-x64.zip -D plonds-assets --clobber
|
||||
test -f plonds-assets/changed.zip
|
||||
test -f plonds-assets/PLONDS.json
|
||||
test -f plonds-assets/files-windows-x64.zip
|
||||
jq -e . plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Prepare PLONDS static output
|
||||
- name: Publish PLONDS assets to Rainyun S3
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf plonds-static
|
||||
mkdir -p plonds-static
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
|
||||
unzip -q release-assets/plonds-static.zip -d plonds-static
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
|
||||
echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
|
||||
if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
|
||||
echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload release assets to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws --version
|
||||
for file in release-assets/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
name="$(basename "$file")"
|
||||
if [[ "$name" == "plonds.json" || "$name" == "plonds.json.sig" ]]; then
|
||||
continue
|
||||
fi
|
||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
|
||||
if [[ "$existing_sha" == "$sha256" ]]; then
|
||||
echo "Skip existing asset: $name"
|
||||
continue
|
||||
fi
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" \
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
REGION="${S3_REGION:-us-east-1}"
|
||||
PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
|
||||
- name: Upload PLONDS static output to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
|
||||
plonds-static/ \
|
||||
"s3://$S3_BUCKET/lanmountain/update/" \
|
||||
--only-show-errors
|
||||
|
||||
- name: Mirror installers to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${RELEASE_TAG#v}"
|
||||
for file in release-assets/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
name="$(basename "$file")"
|
||||
platform=""
|
||||
case "$name" in
|
||||
*.exe)
|
||||
if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
|
||||
;;
|
||||
*.deb)
|
||||
platform="linux-x64"
|
||||
;;
|
||||
*.dmg)
|
||||
if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
|
||||
;;
|
||||
esac
|
||||
[[ -n "$platform" ]] || continue
|
||||
key="lanmountain/update/installers/${platform}/${version}/${name}"
|
||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" \
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Build PLONDS manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p plonds-output
|
||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||
build-plonds \
|
||||
publish-s3 \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--assets-dir release-assets \
|
||||
--output-dir plonds-output \
|
||||
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--s3-base-url "$S3_BASE_URL"
|
||||
--manifest "$PWD/plonds-assets/PLONDS.json" \
|
||||
--changed-zip "$PWD/plonds-assets/changed.zip" \
|
||||
--files-zip "$PWD/plonds-assets/files-windows-x64.zip" \
|
||||
--work-dir "$PWD/plonds-publish-work" \
|
||||
--s3-prefix "$PLONDS_S3_PREFIX" \
|
||||
--s3-endpoint "$S3_ENDPOINT" \
|
||||
--s3-region "$REGION" \
|
||||
--s3-bucket "$S3_BUCKET" \
|
||||
--s3-access-key "$S3_ACCESS_KEY" \
|
||||
--s3-secret-key "$S3_SECRET_KEY" \
|
||||
--s3-public-base-url "$PUBLIC_BASE" \
|
||||
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
|
||||
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY" \
|
||||
--multipart-threshold-mb "$PLONDS_S3_MULTIPART_THRESHOLD_MB" \
|
||||
--multipart-part-size-mb "$PLONDS_S3_MULTIPART_PART_SIZE_MB" \
|
||||
--multipart-concurrency "$PLONDS_S3_MULTIPART_CONCURRENCY"
|
||||
|
||||
- name: Validate PLONDS asset references in Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' plonds-output/plonds.json \
|
||||
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
|
||||
| sort -u)
|
||||
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
if [[ -z "$keys" ]]; then
|
||||
echo "No S3-backed asset URLs found in plonds.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r key; do
|
||||
[[ -n "$key" ]] || continue
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
done <<< "$keys"
|
||||
|
||||
- name: Upload PLONDS manifest to release
|
||||
- name: Upload enriched PLONDS manifest to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber
|
||||
|
||||
- name: Upload PLONDS manifest to Rainyun S3 staging
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in plonds-output/plonds.json plonds-output/plonds.json.sig; do
|
||||
name="$(basename "$file")"
|
||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" \
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Prepare PLONDS channel pointer
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pointer_file="plonds-output/plonds-latest.json"
|
||||
cat > "$pointer_file" <<'JSON'
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"channel": "__CHANNEL__",
|
||||
"releaseTag": "__TAG__",
|
||||
"version": "__VERSION__",
|
||||
"updatedAt": "__UPDATED_AT__",
|
||||
"manifest": {
|
||||
"url": "__MANIFEST_URL__",
|
||||
"signatureUrl": "__SIG_URL__"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
manifest_url="${S3_BASE_URL}/plonds.json"
|
||||
sig_url="${S3_BASE_URL}/plonds.json.sig"
|
||||
version="${RELEASE_TAG#v}"
|
||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file"
|
||||
sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file"
|
||||
sed -i "s|__VERSION__|${version}|g" "$pointer_file"
|
||||
sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file"
|
||||
sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file"
|
||||
sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file"
|
||||
|
||||
jq -e . "$pointer_file" >/dev/null
|
||||
|
||||
- name: Atomically publish PLONDS channel pointer
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pointer_file="plonds-output/plonds-latest.json"
|
||||
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-latest.json"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$staging_key" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
|
||||
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
|
||||
|
||||
- name: Verify Rainyun S3 PLONDS output
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t required < <(
|
||||
{
|
||||
find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
|
||||
find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
|
||||
find plonds-static/repo/sha256 -type f | sort | head -n 1
|
||||
} | sed '/^$/d'
|
||||
)
|
||||
|
||||
if [[ "${#required[@]}" -lt 5 ]]; then
|
||||
echo "Not enough PLONDS static files to verify."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for path in "${required[@]}"; do
|
||||
rel="${path#plonds-static/}"
|
||||
key="lanmountain/update/${rel}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
|
||||
done
|
||||
gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --clobber
|
||||
|
||||
101
.github/workflows/release.yml
vendored
101
.github/workflows/release.yml
vendored
@@ -185,6 +185,29 @@ jobs:
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/airapp-runtime-win-$arch"
|
||||
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
|
||||
-c Release `
|
||||
-o ./$publishDir `
|
||||
--self-contained:false `
|
||||
-r win-$arch `
|
||||
-p:SelfContained=false `
|
||||
-p:PublishAot=false `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:PublishTrimmed=false `
|
||||
-p:PublishReadyToRun=false `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false `
|
||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish AirAppHost
|
||||
run: |
|
||||
$arch = "${{ matrix.arch }}"
|
||||
@@ -215,6 +238,7 @@ jobs:
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||
$appDir = "app-$version"
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
|
||||
@@ -226,10 +250,15 @@ jobs:
|
||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path $runtimePublishDir) {
|
||||
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||
|
||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
shell: pwsh
|
||||
|
||||
@@ -253,6 +282,7 @@ jobs:
|
||||
|
||||
$requiredFiles = @(
|
||||
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
||||
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
|
||||
(Join-Path $appDir "LanMountainDesktop.exe"),
|
||||
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
||||
)
|
||||
@@ -330,7 +360,7 @@ jobs:
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||
$payloadRoot = Join-Path $PWD "publish/windows-$arch"
|
||||
if (-not (Test-Path $payloadRoot)) {
|
||||
Write-Error "Payload root not found: $payloadRoot"
|
||||
exit 1
|
||||
@@ -344,7 +374,7 @@ jobs:
|
||||
|
||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
if ($relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -462,12 +492,32 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||
-c Release \
|
||||
-o ./publish/airapp-runtime-linux-x64 \
|
||||
--self-contained false \
|
||||
-r linux-x64 \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishAot=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
publishDir="publish/linux-x64"
|
||||
appDir="app-$version"
|
||||
launcherDir="publish/launcher-linux-x64"
|
||||
runtimeDir="publish/airapp-runtime-linux-x64"
|
||||
|
||||
mkdir -p "$publishDir"
|
||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||
@@ -477,8 +527,13 @@ jobs:
|
||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -d "$runtimeDir" ]; then
|
||||
cp -r "$runtimeDir"/* "$publishDir/"
|
||||
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
touch "$publishDir/$appDir/.current"
|
||||
rm -rf "$launcherDir"
|
||||
rm -rf "$launcherDir" "$runtimeDir"
|
||||
|
||||
- name: Package as DEB
|
||||
run: |
|
||||
@@ -637,10 +692,10 @@ jobs:
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||
-c Release \
|
||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||
--self-contained \
|
||||
--self-contained:false \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:SelfContained=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:SkipAirAppHostBuild=true \
|
||||
@@ -651,6 +706,36 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||
-c Release \
|
||||
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
|
||||
--self-contained false \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishAot=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Optimize and Guard macOS Payload
|
||||
run: |
|
||||
arch="${{ matrix.arch }}"
|
||||
publishDir="publish/macos-${arch}-app"
|
||||
|
||||
pwsh ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 \
|
||||
-PublishDir "$publishDir" \
|
||||
-RuntimeIdentifier "osx-${arch}" \
|
||||
-AssertClean
|
||||
shell: bash
|
||||
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
release_dir="$PWD/release-assets"
|
||||
@@ -673,6 +758,7 @@ jobs:
|
||||
app_name="LanMountainDesktop"
|
||||
package_name="${app_name}-${version}-macos-${arch}"
|
||||
launcherDir="publish/launcher-macos-$arch"
|
||||
runtimeDir="publish/airapp-runtime-macos-$arch"
|
||||
appSourceDir="publish/macos-$arch-app"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||
@@ -685,6 +771,11 @@ jobs:
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -d "$runtimeDir" ]; then
|
||||
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||
mkdir -p "${app_name}.app/Contents/Resources"
|
||||
|
||||
|
||||
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
9
.trae/specs/air-app-runtime-container/checklist.md
Normal 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.
|
||||
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
21
.trae/specs/air-app-runtime-container/spec.md
Normal 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.
|
||||
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
11
.trae/specs/air-app-runtime-container/tasks.md
Normal 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.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Checklist
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||
|
||||
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
||||
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
||||
- [x] `LanMountainDesktop` builds in Debug.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Launcher Managed Air APP Lifecycle
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
|
||||
|
||||
## Goal
|
||||
|
||||
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Tasks
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||
|
||||
- [x] Add shared Air APP lifecycle IPC contracts.
|
||||
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
||||
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- [ ] New install shows OOBE once.
|
||||
- [ ] Same-user reinstall does not show OOBE again.
|
||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
||||
- [ ] `plugin-install` does not auto-enter OOBE.
|
||||
- [ ] Default plugin install does not request UAC.
|
||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
||||
|
||||
@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
|
||||
- `launchSource` values are treated as:
|
||||
- `normal`
|
||||
- `postinstall`
|
||||
- `apply-update`
|
||||
- `plugin-install`
|
||||
- `debug-preview`
|
||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to:
|
||||
- the installer itself
|
||||
- full installer update application
|
||||
|
||||
174
.trae/specs/plonds-client-service/spec.md
Normal file
174
.trae/specs/plonds-client-service/spec.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# PLONDS Client Service 独立化设计
|
||||
|
||||
> 日期:2026-06-01
|
||||
> 状态:设计中
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PLONDS 在应用内必须作为独立服务存在,负责分发发现、下载、校验和本地包准备。它不是现有 Update 模块的 provider,也不应把 S3/GitHub/source 选择逻辑混入 `LanMountainDesktop/Services/Update/`。
|
||||
|
||||
最终边界:
|
||||
|
||||
- PLONDS 服务:寻找最新版本、选择下载源、下载 manifest 和包、校验文件、准备本地 staging。
|
||||
- 安装程序/安装网关:只消费 PLONDS 已准备好的本地安装输入,执行增量安装或完整安装。
|
||||
- UI:只展示 PLONDS 服务和安装程序返回的状态;完整包也失败后才处理错误。
|
||||
|
||||
## 2. 当前耦合点
|
||||
|
||||
当前需要拆离的耦合点:
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||
- 直接持有 `PlondsStaticUpdateService` 与 `PlondsReleaseUpdateService`
|
||||
- 在 `CheckForUpdatesCoreAsync` 中把 PLONDS 和 GitHub Update fallback 逻辑混在一起
|
||||
- `LanMountainDesktop/Services/Update/UpdateInstallGateway.cs`
|
||||
- 直接判断 `UpdatePayloadKind.DeltaPlonds`
|
||||
- 直接实例化 `PlondsUpdateApplier`
|
||||
- `LanMountainDesktop/Services/Update/Plonds*.cs`
|
||||
- PLONDS apply/parser/payload resolver 仍位于 Update 命名空间
|
||||
|
||||
## 3. Source 发现规则
|
||||
|
||||
PLONDS 客户端内置两个初始地址:
|
||||
|
||||
1. S3 上的 PLONDS manifest 地址
|
||||
2. GitHub Release 上的 PLONDS manifest 地址
|
||||
|
||||
两个地址读取的是同一种 JSON 文件,当前文件名为 `PLONDS.json`。客户端每次检查增量更新时,会并行或顺序请求所有已知 source 的 `PLONDS.json`。
|
||||
|
||||
### 3.1 Source 扩展
|
||||
|
||||
`PLONDS.json` 可以声明额外 source。客户端读取到额外 source 后,应把它们加入下一轮寻找列表。
|
||||
|
||||
建议 manifest 扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "rainyun-s3",
|
||||
"kind": "s3",
|
||||
"manifestUrl": "https://example.com/plonds/1.2.3/PLONDS.json",
|
||||
"priority": 100
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"kind": "github",
|
||||
"manifestUrl": "https://github.com/owner/repo/releases/download/v1.2.3/PLONDS.json",
|
||||
"priority": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `sources` 为空或缺失时,只使用内置 S3 + GitHub。
|
||||
- 新 source 不覆盖内置 source,除非 `id` 相同。
|
||||
- source 列表需要去重,按 `id` 和 `manifestUrl` 双重去重。
|
||||
- source 持久化到 PLONDS 自己的配置/缓存,不写入 Update 设置。
|
||||
|
||||
## 4. 版本选择规则
|
||||
|
||||
如果多个 source 返回的版本不一致,客户端选择 `currentVersion` 最高的 manifest。
|
||||
|
||||
规则:
|
||||
|
||||
- 版本解析使用 `Version` 语义,忽略前导 `v`。
|
||||
- 版本相同时,优先选择下载可用性更高的 source。
|
||||
- 如果最高版本 manifest 下载包失败,可以尝试同版本的其他 source。
|
||||
- 不因为低版本 source 成功而降级,除非用户显式允许。
|
||||
|
||||
## 5. 下载与回退规则
|
||||
|
||||
PLONDS 服务优先走增量包:
|
||||
|
||||
1. 下载所选 manifest。
|
||||
2. 下载 `changed.zip`。
|
||||
3. 校验 `changed.zip` 与 manifest 中的 hash/checksum。
|
||||
4. 解压或准备增量 staging。
|
||||
5. 交给安装程序执行增量安装。
|
||||
|
||||
如果增量流程失败,PLONDS 服务自动改用完整包:
|
||||
|
||||
1. 下载 `Files.zip`。
|
||||
2. 校验 `Files.zip`。
|
||||
3. 解压或准备完整包 staging。
|
||||
4. 交给安装程序执行完整包安装。
|
||||
|
||||
如果完整包也失败,PLONDS 服务返回失败结果,由 UI 展示错误和重试入口。
|
||||
|
||||
## 6. 发布产物布局
|
||||
|
||||
Publisher 上传到 S3 的版本目录:
|
||||
|
||||
```text
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
||||
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
||||
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
|
||||
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
||||
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||
- Publisher 必须先完成版本目录内的 `changed.zip`、`Files.zip`、解压目录和版本 `PLONDS.json` 上传,再更新 `<prefix>/PLONDS.json` latest 指针。
|
||||
- Publisher 的 S3 目录上传必须支持重跑续传;同 key 且大小一致的对象可以跳过,避免失败后从头上传完整包目录。
|
||||
- Publisher 上传大对象时应使用 S3 multipart upload,以避免 `changed.zip` / `Files.zip` 在低吞吐链路上被单次 PUT 长时间阻塞。
|
||||
|
||||
## 7. 建议代码结构
|
||||
|
||||
```text
|
||||
LanMountainDesktop/Services/Plonds/
|
||||
IPlondsService.cs
|
||||
PlondsService.cs
|
||||
Sources/
|
||||
IPlondsSource.cs
|
||||
PlondsHttpManifestSource.cs
|
||||
PlondsSourceRegistry.cs
|
||||
Download/
|
||||
PlondsDownloader.cs
|
||||
PlondsDownloadPlanner.cs
|
||||
Verification/
|
||||
PlondsVerifier.cs
|
||||
Staging/
|
||||
PlondsPackageStore.cs
|
||||
PlondsPreparedPackage.cs
|
||||
Models/
|
||||
PlondsClientManifest.cs
|
||||
PlondsSourceDescriptor.cs
|
||||
PlondsCheckResult.cs
|
||||
```
|
||||
|
||||
后续如果要移植,优先把这棵目录或等价项目抽成独立库。
|
||||
|
||||
## 8. 与安装程序的交接契约
|
||||
|
||||
PLONDS 服务输出本地 prepared package:
|
||||
|
||||
```csharp
|
||||
public sealed record PlondsPreparedPackage(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string ManifestPath,
|
||||
string? ChangedZipPath,
|
||||
string? ChangedDirectory,
|
||||
string? FilesZipPath,
|
||||
string? FilesDirectory);
|
||||
```
|
||||
|
||||
安装程序只接受这个结果,不参与 source 发现、下载和校验。
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
1. Publisher 补齐完整包 S3 上传与 manifest downloads 字段。
|
||||
2. 新增 `Services/Plonds/` 客户端服务骨架和模型。
|
||||
3. 把 `PlondsStaticUpdateService` / `PlondsReleaseUpdateService` 合并迁移到独立 PLONDS source 体系。
|
||||
4. 把 `LanMountainDesktop/Services/Update/Plonds*.cs` 迁出 Update 命名空间。
|
||||
5. `UpdateSettingsService` 改为调用 `IPlondsService`,不再直接组合 S3/GitHub PLONDS fallback。
|
||||
6. 安装入口只接收 `PlondsPreparedPackage`。
|
||||
7. 添加单元测试覆盖 source 扩展、最高版本选择、增量失败转完整包、完整包失败交 UI。
|
||||
@@ -250,7 +250,8 @@ public sealed record PlondsChangedFileEntry(
|
||||
### 6.1 保留两个工作流
|
||||
|
||||
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
||||
- **Publisher**(`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
|
||||
- **Publisher**(`plonds-uploader.yml`):发布器,负责用仓库内 C# S3 客户端上传 `changed.zip`、`PLONDS.json` 和解压后的 `<version>-changed/` 目录,并把 GitHub/S3 下载信息写回 `PLONDS.json`
|
||||
- **Rollback**:独立 rollback 工作流已废弃,不再维护
|
||||
|
||||
### 6.2 Comparator 改造后步骤
|
||||
|
||||
@@ -297,7 +298,43 @@ jobs:
|
||||
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
||||
```
|
||||
|
||||
### 6.3 与当前步骤的差异
|
||||
### 6.3 Publisher 改造后步骤
|
||||
|
||||
```yaml
|
||||
# plonds-uploader.yml
|
||||
触发: PLONDS Comparator completed / workflow_dispatch
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- Checkout
|
||||
- 解析 release tag
|
||||
- Setup .NET
|
||||
- 构建 PLONDS Tool
|
||||
- 从 GitHub Release 下载 changed.zip + PLONDS.json
|
||||
- 调用 dotnet run Plonds.Tool -- publish-s3
|
||||
→ 使用仓库内 C# S3 客户端上传,不依赖 aws CLI
|
||||
→ S3 目录布局:
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
→ 回写 PLONDS.json downloads 字段:
|
||||
downloads.github.releaseUrl
|
||||
downloads.github.manifestUrl
|
||||
downloads.github.changedZipUrl
|
||||
downloads.github.filesZipUrl
|
||||
downloads.s3.manifestUrl
|
||||
downloads.s3.changedZipUrl
|
||||
downloads.s3.changedFolderUrl
|
||||
downloads.s3.filesZipUrl
|
||||
downloads.s3.filesFolderUrl
|
||||
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
|
||||
```
|
||||
|
||||
### 6.4 与当前步骤的差异
|
||||
|
||||
| 当前步骤 | 改造后 |
|
||||
|---------|--------|
|
||||
@@ -307,6 +344,8 @@ jobs:
|
||||
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
|
||||
| 独立 rollback workflow | ❌ 删除 |
|
||||
|
||||
## 7. 双模式差分生成
|
||||
|
||||
@@ -504,9 +543,7 @@ build-delta-from-commits --platform <platform>
|
||||
|
||||
## 8. 不在本次改造范围内的事项
|
||||
|
||||
- Publisher 工作流改造(后续单独设计)
|
||||
- Rollback 工作流改造(后续单独设计)
|
||||
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||
- Launcher 侧客户端代码改造(后续单独设计)
|
||||
- Plonds.Api 项目处置(后续决定是否保留)
|
||||
- `build-index`、`build-plonds`、`generate`、`publish`、`sign`、`pack-payload` 等 Tool 命令的清理(后续处理)
|
||||
- `build-index`、`generate`、`publish`、`sign` 等旧 Tool 命令的清理(后续处理)
|
||||
|
||||
@@ -8,7 +8,10 @@ Rebuild the settings window as an independent Fluent shell with a custom titleba
|
||||
|
||||
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
||||
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
||||
- Keep the titlebar and content area on one shared full-window background layer; the custom titlebar must remain transparent and must not paint a contrasting strip.
|
||||
- Avoid a visible titlebar bottom divider that makes the titlebar read as a separate color band.
|
||||
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
||||
- Keep `FANavigationView` pane and content template backgrounds transparent in the settings shell so the navigation control does not reintroduce a second surface color.
|
||||
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
||||
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
||||
- Add `auto` system material mode and make it the default.
|
||||
|
||||
@@ -15,7 +15,7 @@ Make the Settings > Update page the single user-facing control surface for the h
|
||||
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
||||
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
||||
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
||||
- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged.
|
||||
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
|
||||
|
||||
## Acceptance
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
|
||||
|
||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
|
||||
|
||||
## Migration Note
|
||||
|
||||
|
||||
1125
CODE_WIKI.md
1125
CODE_WIKI.md
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppHostLocator
|
||||
{
|
||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
||||
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
|
||||
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
||||
|
||||
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||
? WindowsExecutableName
|
||||
: UnixExecutableName;
|
||||
|
||||
public string Resolve(string? packageRoot, string? hostPath = null)
|
||||
{
|
||||
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
||||
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
|
||||
{
|
||||
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
||||
{
|
||||
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(root, "AirAppHost", ExecutableName);
|
||||
yield return Path.Combine(root, "AirAppHost", DllName);
|
||||
yield return Path.Combine(root, WindowsExecutableName);
|
||||
yield return Path.Combine(root, ExecutableName);
|
||||
yield return Path.Combine(root, DllName);
|
||||
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
||||
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, ExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, DllName);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
|
||||
"Release",
|
||||
#endif
|
||||
"net10.0",
|
||||
WindowsExecutableName);
|
||||
ExecutableName);
|
||||
|
||||
yield return Path.Combine(
|
||||
current.FullName,
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal static class AirAppInstanceKey
|
||||
{
|
||||
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@ using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IAirAppProcessStarter _processStarter;
|
||||
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||
{
|
||||
_processStarter = processStarter;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var appId = Normalize(request.AppId, "unknown");
|
||||
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
||||
|
||||
lock (_gate)
|
||||
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
(processId <= 0 || instance.ProcessId == processId))
|
||||
{
|
||||
_instances.Remove(instanceKey);
|
||||
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
foreach (var key in exitedKeys)
|
||||
{
|
||||
_instances.Remove(key);
|
||||
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsProcessAlive(int processId)
|
||||
internal static bool IsProcessAlive(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
private const int SW_SHOWNORMAL = 1;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal 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}");
|
||||
}
|
||||
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal interface IAirAppProcessStarter
|
||||
{
|
||||
@@ -12,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
private readonly Func<string?> _packageRootProvider;
|
||||
private readonly Func<string?> _hostPathProvider;
|
||||
private readonly Func<string?> _dataRootProvider;
|
||||
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
|
||||
|
||||
public AirAppProcessStarter(
|
||||
AirAppHostLocator locator,
|
||||
Func<string?> packageRootProvider,
|
||||
Func<string?> hostPathProvider,
|
||||
Func<string?> dataRootProvider,
|
||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||
Func<string?> dataRootProvider)
|
||||
{
|
||||
_locator = locator;
|
||||
_packageRootProvider = packageRootProvider;
|
||||
_hostPathProvider = hostPathProvider;
|
||||
_dataRootProvider = dataRootProvider;
|
||||
_runtimeProbeOptions = runtimeProbeOptions;
|
||||
}
|
||||
|
||||
public Process? Start(
|
||||
@@ -36,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
string? sourcePlacementId)
|
||||
{
|
||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
|
||||
var startInfo = CreateStartInfo(hostPath);
|
||||
|
||||
AddArgument(startInfo, "--app-id", appId);
|
||||
AddArgument(startInfo, "--session-id", sessionId);
|
||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
||||
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
|
||||
var dataRoot = _dataRootProvider();
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
@@ -58,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||
}
|
||||
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is not null)
|
||||
@@ -68,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -81,54 +80,11 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
return process;
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateStartInfo(
|
||||
string hostPath,
|
||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||
internal static ProcessStartInfo CreateStartInfo(string hostPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
|
||||
{
|
||||
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||
if (!executableRuntime.IsAvailable)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||
executableRuntime.Message);
|
||||
}
|
||||
}
|
||||
|
||||
startInfo.FileName = hostPath;
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||
runtime.Message);
|
||||
}
|
||||
|
||||
startInfo.FileName = runtime.DotNetHostPath;
|
||||
startInfo.ArgumentList.Add(hostPath);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
startInfo.FileName = "dotnet";
|
||||
startInfo.ArgumentList.Add(hostPath);
|
||||
return startInfo;
|
||||
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||
}
|
||||
|
||||
|
||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||
{
|
||||
startInfo.ArgumentList.Add(name);
|
||||
@@ -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>
|
||||
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,6 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.IsAirAppBrokerCommand)
|
||||
{
|
||||
_ = AirAppBrokerEntryHandler.RunAsync(desktop, context);
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.IsDebugMode && !context.IsPreviewCommand)
|
||||
{
|
||||
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
||||
|
||||
@@ -11,15 +11,7 @@ namespace LanMountainDesktop.Launcher;
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true)]
|
||||
[JsonSerializable(typeof(SignedFileMap))]
|
||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
||||
[JsonSerializable(typeof(PlondsFileMap))]
|
||||
[JsonSerializable(typeof(PlondsComponentEntry))]
|
||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
||||
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||
[JsonSerializable(typeof(InstallCheckpoint))]
|
||||
[JsonSerializable(typeof(AppVersionInfo))]
|
||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
||||
@@ -37,11 +29,11 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||
[JsonSerializable(typeof(PrivacyConfig))]
|
||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
||||
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
|
||||
[JsonSerializable(typeof(AirAppRuntimeStatus))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -4,14 +4,11 @@ namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal sealed class CommandContext
|
||||
{
|
||||
public const string AirAppBrokerCommand = "air-app-broker";
|
||||
|
||||
private const string LaunchSourceOptionName = "launch-source";
|
||||
|
||||
private static readonly string[] GuiCommands =
|
||||
[
|
||||
"launch",
|
||||
AirAppBrokerCommand,
|
||||
"preview-splash",
|
||||
"preview-error",
|
||||
"preview-update",
|
||||
@@ -62,15 +59,11 @@ internal sealed class CommandContext
|
||||
public bool IsPreviewCommand =>
|
||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsAirAppBrokerCommand =>
|
||||
string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGuiCommand =>
|
||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsMaintenanceCommand =>
|
||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string? ExplicitAppRoot => GetOption("app-root");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
global using LanMountainDesktop.Launcher.AirApp;
|
||||
global using LanMountainDesktop.Launcher.Deployment;
|
||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
|
||||
@@ -14,7 +14,7 @@ internal static class Commands
|
||||
{
|
||||
var source = context.GetOption("source") ?? string.Empty;
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||
result = installer.InstallPackage(source, pluginsDir);
|
||||
result = installer.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -91,12 +91,12 @@ internal static class Commands
|
||||
{
|
||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
default:
|
||||
return new LauncherResult
|
||||
|
||||
@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
|
||||
|
||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
||||
? Path.GetFullPath(customPath)
|
||||
var targetDataRoot = mode == DataLocationMode.Portable
|
||||
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||
? customPath
|
||||
: DefaultPortableDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
var config = new DataLocationConfig
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
||||
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
|
||||
<PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PublicKeyDestDir)" />
|
||||
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
||||
@@ -55,7 +55,7 @@
|
||||
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
||||
<PropertyGroup>
|
||||
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
||||
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
|
||||
<PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
||||
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新频道
|
||||
/// </summary>
|
||||
public enum UpdateChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 正式版 - 只检查 prerelease=false 的版本
|
||||
/// </summary>
|
||||
Stable,
|
||||
|
||||
/// <summary>
|
||||
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
||||
/// </summary>
|
||||
Preview
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,29 +1,5 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class SignedFileMap
|
||||
{
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class UpdateFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string Action { get; set; } = "replace";
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SnapshotMetadata
|
||||
{
|
||||
public string SnapshotId { get; set; } = string.Empty;
|
||||
@@ -40,124 +16,3 @@ internal sealed class SnapshotMetadata
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
}
|
||||
|
||||
internal sealed class InstallCheckpoint
|
||||
{
|
||||
public string SnapshotId { get; set; } = string.Empty;
|
||||
|
||||
public string SourceVersion { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetVersion { get; set; }
|
||||
|
||||
public string? SourceDirectory { get; set; }
|
||||
|
||||
public string TargetDirectory { get; set; } = string.Empty;
|
||||
|
||||
public bool IsInitialDeployment { get; set; }
|
||||
|
||||
public int AppliedCount { get; set; }
|
||||
|
||||
public int VerifiedCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class UpdateApplyResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public string? FromVersion { get; init; }
|
||||
|
||||
public string? ToVersion { get; init; }
|
||||
|
||||
public string? RolledBackTo { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PlondsUpdateMetadata
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? SubChannel { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? FileMapPath { get; set; }
|
||||
|
||||
public string? FileMapSignaturePath { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsFileMap
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PlondsComponentEntry> Components { get; set; } = [];
|
||||
|
||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsComponentEntry
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? Action { get; set; } = "replace";
|
||||
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string? ObjectUrl { get; set; }
|
||||
|
||||
public string? ObjectPath { get; set; }
|
||||
|
||||
public string? ObjectKey { get; set; }
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
public string? Sha512 { get; set; }
|
||||
|
||||
public string? Sha512Base64 { get; set; }
|
||||
|
||||
public byte[]? Sha512Bytes { get; set; }
|
||||
|
||||
public PlondsHashDescriptor? Hash { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsHashDescriptor
|
||||
{
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
public string? Value { get; set; }
|
||||
|
||||
public byte[]? Bytes { get; set; }
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory, string? appRoot = null)
|
||||
{
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
|
||||
{
|
||||
return elevationRequiredResult;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ internal sealed class PluginInstallerService
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory, string? appRoot)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
|
||||
string? allowedRoot = null;
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(resolvedAppRoot);
|
||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
_installerService = installerService;
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
|
||||
{
|
||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||
if (!File.Exists(pendingPath))
|
||||
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
|
||||
try
|
||||
{
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
|
||||
succeeded.Add(item);
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -57,14 +57,6 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Update Check)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "update check",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Plugin Install)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||
|
||||
83
LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
Normal file
83
LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||
@@ -30,52 +28,3 @@ internal static class LaunchEntryHandler
|
||||
SplashWindow splashWindow) =>
|
||||
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
|
||||
internal static class AirAppBrokerEntryHandler
|
||||
{
|
||||
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var requesterPid = context.GetIntOption("requester-pid", 0);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
while (ShouldKeepAlive(requesterPid, airAppIpcHost.LifecycleService))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Air APP broker exiting.");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
internal static bool ShouldKeepAirAppBrokerAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService)
|
||||
{
|
||||
if (requesterPid <= 0)
|
||||
{
|
||||
return lifecycleService.HasLiveAirApps();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = System.Diagnostics.Process.GetProcessById(requesterPid);
|
||||
return !process.HasExited || lifecycleService.HasLiveAirApps();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return lifecycleService.HasLiveAirApps();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldKeepAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) =>
|
||||
ShouldKeepAirAppBrokerAlive(requesterPid, lifecycleService);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ internal static class LauncherGuiCoordinator
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
|
||||
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
@@ -44,15 +47,6 @@ internal static class LauncherGuiCoordinator
|
||||
return;
|
||||
}
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
@@ -129,7 +123,8 @@ internal static class LauncherGuiCoordinator
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
@@ -173,17 +168,15 @@ internal static class LauncherGuiCoordinator
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForManagedProcessesToExitAsync(
|
||||
int hostPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
private static async Task WaitForHostProcessToExitAsync(int hostPid)
|
||||
{
|
||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
||||
Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
||||
Logger.Info("Launcher host background lifetime completed; host process is gone.");
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
77
LanMountainDesktop.Shared.IPC/AirAppRuntimePathResolver.cs
Normal file
77
LanMountainDesktop.Shared.IPC/AirAppRuntimePathResolver.cs
Normal 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, ".."));
|
||||
}
|
||||
}
|
||||
101
LanMountainDesktop.Shared.IPC/AirAppRuntimeProcessStarter.cs
Normal file
101
LanMountainDesktop.Shared.IPC/AirAppRuntimeProcessStarter.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ public static class IpcConstants
|
||||
|
||||
public const string ProtocolVersion = "external-ipc-public-api.v1";
|
||||
|
||||
public const string AirAppLifecyclePipeName = "LanMountainDesktop.Launcher.AirApp.v1";
|
||||
public const string AirAppRuntimePipeName = "LanMountainDesktop.AirAppRuntime.v1";
|
||||
|
||||
[Obsolete("Use AirAppRuntimePipeName. The lifecycle service is now hosted by LanMountainDesktop.AirAppRuntime.")]
|
||||
public const string AirAppLifecyclePipeName = AirAppRuntimePipeName;
|
||||
|
||||
public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";
|
||||
|
||||
|
||||
@@ -96,17 +96,17 @@ public sealed class AirAppLauncherServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
|
||||
public void CreateRuntimeStartInfo_UsesAirAppRuntimeAndRequesterPid()
|
||||
{
|
||||
var startInfo = AirAppLauncherService.CreateBrokerStartInfo(
|
||||
@"C:\Apps\LanMountainDesktop.Launcher.exe",
|
||||
var startInfo = AirAppLauncherService.CreateRuntimeStartInfo(
|
||||
@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe",
|
||||
12345);
|
||||
|
||||
Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName);
|
||||
Assert.Equal(@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe", startInfo.FileName);
|
||||
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
|
||||
Assert.False(startInfo.UseShellExecute);
|
||||
Assert.Equal(
|
||||
["air-app-broker", "--requester-pid", "12345"],
|
||||
["--requester-pid", "12345"],
|
||||
startInfo.ArgumentList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using LanMountainDesktop.Launcher.AirApp;
|
||||
using LanMountainDesktop.Launcher.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -29,38 +27,14 @@ public sealed class AirAppProcessStarterRuntimeTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
|
||||
public void CreateStartInfo_UsesDotnetHost_ForDllFallback()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var programFiles = Path.Combine(_root, "ProgramFiles");
|
||||
var dotnetRoot = Path.Combine(programFiles, "dotnet");
|
||||
Directory.CreateDirectory(dotnetRoot);
|
||||
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
|
||||
File.WriteAllText(dotnetHost, string.Empty);
|
||||
Directory.CreateDirectory(Path.Combine(
|
||||
dotnetRoot,
|
||||
"shared",
|
||||
DotNetRuntimeProbe.RequiredSharedFrameworkName,
|
||||
"10.0.5"));
|
||||
|
||||
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
|
||||
File.WriteAllText(hostDll, string.Empty);
|
||||
var options = new DotNetRuntimeProbeOptions
|
||||
{
|
||||
Architecture = DotNetRuntimeArchitecture.X64,
|
||||
ProgramFilesPath = programFiles,
|
||||
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
|
||||
IncludeRegistry = false,
|
||||
IncludeDotNetCli = false
|
||||
};
|
||||
|
||||
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
|
||||
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll);
|
||||
|
||||
Assert.Equal(dotnetHost, startInfo.FileName);
|
||||
Assert.Contains("dotnet", Path.GetFileName(startInfo.FileName), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.AirApp;
|
||||
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class LauncherAirAppLifecycleServiceTests
|
||||
public sealed class AirAppRuntimeLifecycleServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
|
||||
{
|
||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||
var service = new LauncherAirAppLifecycleService(starter);
|
||||
var service = new AirAppLifecycleService(starter);
|
||||
var request = new AirAppOpenRequest(
|
||||
"whiteboard",
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
@@ -36,7 +33,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
|
||||
{
|
||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||
var service = new LauncherAirAppLifecycleService(starter);
|
||||
var service = new AirAppLifecycleService(starter);
|
||||
|
||||
var first = await service.OpenAsync(new AirAppOpenRequest(
|
||||
"world-clock",
|
||||
@@ -62,7 +59,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
|
||||
{
|
||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||
var service = new LauncherAirAppLifecycleService(starter);
|
||||
var service = new AirAppLifecycleService(starter);
|
||||
var instanceKey = AirAppInstanceKey.Build(
|
||||
"whiteboard",
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
@@ -92,7 +89,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
[Fact]
|
||||
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
|
||||
|
||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||
@@ -112,26 +109,35 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive()
|
||||
public void RuntimeLifetime_KeepsAliveWhileRequesterIsAlive()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, 0, Environment.ProcessId),
|
||||
service);
|
||||
|
||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
|
||||
Assert.True(lifetime.ShouldKeepAlive());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain()
|
||||
public void RuntimeLifetime_StopsWhenNoProcessOrAirAppsRemain()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||
service);
|
||||
|
||||
Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
||||
Assert.False(lifetime.ShouldKeepAlive());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive()
|
||||
public async Task RuntimeLifetime_KeepsAliveWhileAirAppIsAlive()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||
service);
|
||||
|
||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||
instanceKey,
|
||||
@@ -142,28 +148,23 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
BuiltInComponentIds.DesktopWorldClock,
|
||||
"clock-2"));
|
||||
|
||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
||||
Assert.True(lifetime.ShouldKeepAlive());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment()
|
||||
public async Task RuntimeControl_AttachesHostProcess()
|
||||
{
|
||||
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||
service);
|
||||
var control = new AirAppRuntimeControlService(lifetime);
|
||||
|
||||
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
||||
var result = await control.AttachHostAsync(Environment.ProcessId);
|
||||
|
||||
Assert.True(context.IsGuiCommand);
|
||||
Assert.True(context.IsAirAppBrokerCommand);
|
||||
Assert.True(context.IsDebugMode);
|
||||
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
|
||||
}
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Equal(Environment.ProcessId, result.Status.HostProcessId);
|
||||
Assert.True(result.Status.HostProcessAlive);
|
||||
}
|
||||
|
||||
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter
|
||||
@@ -9,7 +9,6 @@ public sealed class CommandContextTests
|
||||
{
|
||||
{ [], "normal" },
|
||||
{ ["preview-oobe"], "debug-preview" },
|
||||
{ ["apply-update"], "normal" },
|
||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||
};
|
||||
@@ -22,4 +21,12 @@ public sealed class CommandContextTests
|
||||
|
||||
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromArgs_DoesNotTreatAirAppBrokerAsLauncherGuiCommand()
|
||||
{
|
||||
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
||||
|
||||
Assert.False(context.IsGuiCommand);
|
||||
}
|
||||
}
|
||||
|
||||
35
LanMountainDesktop.Tests/DataLocationResolverTests.cs
Normal file
35
LanMountainDesktop.Tests/DataLocationResolverTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
global using LanMountainDesktop.Launcher.AirApp;
|
||||
global using LanMountainDesktop.AirAppRuntime;
|
||||
global using LanMountainDesktop.Launcher.Deployment;
|
||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
|
||||
@@ -11,7 +11,6 @@ public sealed class HostActivationPolicyTests
|
||||
[Theory]
|
||||
[InlineData("launch", "normal", true)]
|
||||
[InlineData("launch", "restart", false)]
|
||||
[InlineData("apply-update", "normal", false)]
|
||||
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
|
||||
string command,
|
||||
string launchSource,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -47,6 +47,95 @@ public sealed class LauncherArchitectureTests
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherProject_DoesNotOwnUpdateApplyOrRollback()
|
||||
{
|
||||
var launcherFiles = Directory
|
||||
.EnumerateFiles(LauncherProjectRoot, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
var forbiddenTokens = new[]
|
||||
{
|
||||
"LauncherUpdateCommandExecutor",
|
||||
"PlondsUpdateApplier",
|
||||
"UpdateRollbackGateway",
|
||||
"UpdateInstallGateway",
|
||||
"LanMountainDesktop.Services.Update",
|
||||
"apply-update",
|
||||
"rollback --app-root"
|
||||
};
|
||||
|
||||
var offenders = launcherFiles
|
||||
.SelectMany(file => forbiddenTokens
|
||||
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherProjectFile_DoesNotSourceLinkHostUpdateImplementation()
|
||||
{
|
||||
var project = File.ReadAllText(Path.Combine(LauncherProjectRoot, "LanMountainDesktop.Launcher.csproj"));
|
||||
|
||||
Assert.DoesNotContain(@"..\LanMountainDesktop\Services\Update", project, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PlondsUpdateApplier", project, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("UpdateRollbackGateway", project, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("UpdateInstallGateway", project, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostUpdateFlow_DoesNotDelegateApplyOrRollbackToLauncher()
|
||||
{
|
||||
var guardedFiles = new[]
|
||||
{
|
||||
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateInstallGateway.cs"),
|
||||
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateOrchestrator.cs")
|
||||
};
|
||||
|
||||
var forbiddenTokens = new[]
|
||||
{
|
||||
"LauncherPathResolver",
|
||||
"ResolveLauncherExecutablePath",
|
||||
"apply-update",
|
||||
"rollback --app-root",
|
||||
"Launched Launcher"
|
||||
};
|
||||
|
||||
var offenders = guardedFiles
|
||||
.SelectMany(file => forbiddenTokens
|
||||
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostUpdateFlow_OwnsDeltaApplyAndRollbackExecution()
|
||||
{
|
||||
var installGateway = File.ReadAllText(Path.Combine(
|
||||
RepoRoot,
|
||||
"LanMountainDesktop",
|
||||
"Services",
|
||||
"Update",
|
||||
"UpdateInstallGateway.cs"));
|
||||
var orchestrator = File.ReadAllText(Path.Combine(
|
||||
RepoRoot,
|
||||
"LanMountainDesktop",
|
||||
"Services",
|
||||
"Update",
|
||||
"UpdateOrchestrator.cs"));
|
||||
|
||||
Assert.Contains("new PlondsUpdateApplier", installGateway, StringComparison.Ordinal);
|
||||
Assert.Contains("DeploymentLockService.ClearLock", installGateway, StringComparison.Ordinal);
|
||||
Assert.Contains("new UpdateRollbackGateway().RollbackLatest", orchestrator, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("LanMountainDesktop.Launcher", orchestrator, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherCompositionRootStaysThin()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
global using LanMountainDesktop.Launcher.AirApp;
|
||||
global using LanMountainDesktop.AirAppRuntime;
|
||||
global using LanMountainDesktop.Launcher.Deployment;
|
||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
|
||||
59
LanMountainDesktop.Tests/LauncherUpdateCommandTests.cs
Normal file
59
LanMountainDesktop.Tests/LauncherUpdateCommandTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@ public sealed class PackagingRuntimePolicyTests
|
||||
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
|
||||
|
||||
Assert.Contains("Publish-LauncherPayload", script);
|
||||
Assert.Contains("Publish-AirAppRuntimePayload", script);
|
||||
Assert.Contains("\"app-$Version\"", script);
|
||||
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
|
||||
Assert.Contains("\"--self-contained\", \"false\"", script);
|
||||
Assert.Contains("\"-p:SelfContained=false\"", script);
|
||||
Assert.Contains("\"-p:PublishAot=false\"", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -28,12 +30,13 @@ public sealed class PackagingRuntimePolicyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost()
|
||||
public void WindowsPayloadGuard_RequiresLauncherRuntimeMainAndAirAppHost()
|
||||
{
|
||||
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
|
||||
|
||||
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
|
||||
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
|
||||
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", script);
|
||||
Assert.Contains("LanMountainDesktop.exe", script);
|
||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
|
||||
}
|
||||
@@ -44,9 +47,21 @@ public sealed class PackagingRuntimePolicyTests
|
||||
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
|
||||
|
||||
Assert.Contains("Verify Windows app host payload", workflow);
|
||||
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", workflow);
|
||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppRuntimeProject_IsFrameworkDependentJit()
|
||||
{
|
||||
var project = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "LanMountainDesktop.AirAppRuntime.csproj");
|
||||
|
||||
Assert.Contains("<PublishAot>false</PublishAot>", project);
|
||||
Assert.Contains("<SelfContained>false</SelfContained>", project);
|
||||
Assert.Contains("<PublishTrimmed>false</PublishTrimmed>", project);
|
||||
Assert.Contains("<PublishReadyToRun>false</PublishReadyToRun>", project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
|
||||
{
|
||||
|
||||
711
LanMountainDesktop.Tests/PlondsClientServiceTests.cs
Normal file
711
LanMountainDesktop.Tests/PlondsClientServiceTests.cs
Normal file
@@ -0,0 +1,711 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.IO.Compression;
|
||||
using LanMountainDesktop.Services.Plonds;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PlondsClientServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.Tests",
|
||||
nameof(PlondsClientServiceTests),
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceRegistry_AddRange_DeduplicatesAndAllowsManifestExtensions()
|
||||
{
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
|
||||
registry.AddRange(
|
||||
[
|
||||
new("mirror", "http", "https://mirror.test/PLONDS.json", 10),
|
||||
new("s3", "s3", "https://s3-new.test/PLONDS.json", 200),
|
||||
new("duplicate-url", "http", "https://mirror.test/PLONDS.json", 1)
|
||||
]);
|
||||
|
||||
Assert.Equal(3, registry.Sources.Count);
|
||||
Assert.Contains(registry.Sources, source => source.Id == "s3" && source.ManifestUrl == "https://s3-new.test/PLONDS.json");
|
||||
Assert.Contains(registry.Sources, source => source.Id == "mirror");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestSelector_WhenVersionsDiffer_SelectsHighestVersion()
|
||||
{
|
||||
var selected = PlondsManifestSelector.SelectHighestVersion(
|
||||
[
|
||||
new(new("s3", "s3", "https://s3.test/PLONDS.json", 100), CreateManifest("1.2.0")),
|
||||
new(new("github", "github", "https://github.test/PLONDS.json", 50), CreateManifest("1.3.0")),
|
||||
new(new("mirror", "http", "https://mirror.test/PLONDS.json", 500), CreateManifest("1.1.9"))
|
||||
]);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal("1.3.0", selected.Manifest.CurrentVersion);
|
||||
Assert.Equal("github", selected.Source.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaFails_FallsBackToFullPackage()
|
||||
{
|
||||
var downloader = new FakeDownloader(deltaFails: true, fullFails: false);
|
||||
var planner = new PlondsDownloadPlanner(downloader);
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/PLONDS.json"), CreateManifest("1.2.3")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.RequiresUiHandling);
|
||||
Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode);
|
||||
Assert.Equal(1, downloader.DeltaCalls);
|
||||
Assert.Equal(1, downloader.FullCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaAndFullFail_ReturnsUiFailure()
|
||||
{
|
||||
var downloader = new FakeDownloader(deltaFails: true, fullFails: true);
|
||||
var planner = new PlondsDownloadPlanner(downloader);
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/PLONDS.json"), CreateManifest("1.2.3")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.RequiresUiHandling);
|
||||
Assert.Null(result.Package);
|
||||
Assert.Contains("full package fallback also failed", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenManifestRequiresCleanInstall_DoesNotPreparePlondsPackage()
|
||||
{
|
||||
var downloader = new FakeDownloader(deltaFails: false, fullFails: false);
|
||||
var planner = new PlondsDownloadPlanner(downloader);
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json"),
|
||||
CreateManifest("1.2.3", requiresCleanInstall: true)),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.RequiresUiHandling);
|
||||
Assert.Contains("clean install", result.ErrorMessage);
|
||||
Assert.Equal(0, downloader.DeltaCalls);
|
||||
Assert.Equal(0, downloader.FullCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_ReadsBuiltInSources_RegistersManifestSources_AndPreparesHighestVersion()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(new Dictionary<string, string>
|
||||
{
|
||||
["https://s3.test/PLONDS.json"] = ManifestJson("1.2.0", """
|
||||
"sources": [
|
||||
{ "id": "mirror", "kind": "http", "manifestUrl": "https://mirror.test/PLONDS.json", "priority": 25 }
|
||||
]
|
||||
"""),
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.3.0")
|
||||
}));
|
||||
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var downloader = new FakeDownloader(deltaFails: false, fullFails: false);
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(downloader));
|
||||
|
||||
var result = await service.FindAndPrepareLatestAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("1.3.0", result.Package?.Version.ToString());
|
||||
Assert.Equal(PlondsPackageMode.Delta, result.Package?.Mode);
|
||||
Assert.Contains(registry.Sources, source => source.Id == "mirror" && source.ManifestUrl == "https://mirror.test/PLONDS.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientServiceFactory_CreatesBuiltInS3AndGitHubSources()
|
||||
{
|
||||
var sources = PlondsClientServiceFactory.CreateBuiltInSources();
|
||||
|
||||
Assert.Equal(2, sources.Count);
|
||||
Assert.Contains(sources, source => source.Id == "s3" && source.Kind == "s3" && source.ManifestUrl.EndsWith("/PLONDS.json", StringComparison.Ordinal));
|
||||
Assert.Contains(sources, source => source.Id == "github" && source.Kind == "github" && source.ManifestUrl.EndsWith("/PLONDS.json", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_FindLatest_UsesHighestVersionAndPersistsManifestSources()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(new Dictionary<string, string>
|
||||
{
|
||||
["https://s3.test/PLONDS.json"] = ManifestJson("1.5.0", """
|
||||
"sources": [
|
||||
{ "id": "mirror", "kind": "http", "manifestUrl": "https://mirror.test/PLONDS.json", "priority": 25 }
|
||||
]
|
||||
"""),
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.4.0")
|
||||
}));
|
||||
var sourceStorePath = Path.Combine(_tempRoot, "sources.json");
|
||||
var sourceStore = new PlondsSourceStore(sourceStorePath);
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(new FakeDownloader(deltaFails: false, fullFails: false)),
|
||||
sourceStore);
|
||||
|
||||
var result = await service.FindLatestAsync(new Version(1, 4, 0), CancellationToken.None);
|
||||
var storedSources = await sourceStore.LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsUpdateAvailable);
|
||||
Assert.Equal("1.5.0", result.LatestVersion?.ToString());
|
||||
Assert.Contains(storedSources, source => source.Id == "mirror" && source.ManifestUrl == "https://mirror.test/PLONDS.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_WhenHighestVersionSourcePackageFails_TriesSameVersionOtherSource()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(new Dictionary<string, string>
|
||||
{
|
||||
["https://s3.test/PLONDS.json"] = ManifestJson("1.6.0"),
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.6.0")
|
||||
}));
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var downloader = new SourceAwareFakeDownloader(failingSourceId: "s3");
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(downloader));
|
||||
|
||||
var result = await service.FindAndPrepareLatestAsync(new Version(1, 5, 0), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("github", downloader.SuccessfulSourceId);
|
||||
Assert.Equal(2, downloader.DeltaCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlondsService_WhenManifestSourceThrows_ContinuesWithOtherSources()
|
||||
{
|
||||
using var httpClient = new HttpClient(new ManifestHandler(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["https://github.test/PLONDS.json"] = ManifestJson("1.7.0")
|
||||
},
|
||||
throwingUrls: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"https://s3.test/PLONDS.json"
|
||||
}));
|
||||
var registry = new PlondsSourceRegistry(
|
||||
[
|
||||
new("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
new("github", "github", "https://github.test/PLONDS.json", 50)
|
||||
]);
|
||||
var service = new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(httpClient),
|
||||
new PlondsDownloadPlanner(new FakeDownloader(deltaFails: false, fullFails: false)));
|
||||
|
||||
var result = await service.FindLatestAsync(new Version(1, 6, 0), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsUpdateAvailable);
|
||||
Assert.Equal("1.7.0", result.LatestVersion?.ToString());
|
||||
Assert.Equal("github", Assert.Single(result.Candidates).Source.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpDownloader_DownloadsVerifiesAndExtractsDeltaPackage()
|
||||
{
|
||||
var changedZip = CreateZip(("app.dll", "delta payload"));
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.0",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: "https://s3.test/1.4.0/changed.zip",
|
||||
filesUrl: "https://s3.test/1.4.0/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = Md5Checksum(changedZip),
|
||||
["Files.zip"] = Md5Checksum(filesZip)
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.0/changed.zip"] = changedZip,
|
||||
["https://s3.test/1.4.0/Files.zip"] = filesZip
|
||||
}));
|
||||
var downloader = CreateHttpDownloader(httpClient);
|
||||
|
||||
var package = await downloader.PrepareDeltaAsync(
|
||||
manifest,
|
||||
new("s3", "s3", "https://s3.test/1.4.0/PLONDS.json", 100),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlondsPackageMode.Delta, package.Mode);
|
||||
Assert.True(File.Exists(package.ManifestPath));
|
||||
Assert.True(File.Exists(package.ChangedZipPath));
|
||||
Assert.Equal("delta payload", File.ReadAllText(Path.Combine(package.ChangedDirectory!, "app.dll")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaChecksumFails_PreparesFullPackage()
|
||||
{
|
||||
var changedZip = CreateZip(("app.dll", "delta payload"));
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.1",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: "https://s3.test/1.4.1/changed.zip",
|
||||
filesUrl: "https://s3.test/1.4.1/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = "md5:00000000000000000000000000000000",
|
||||
["Files.zip"] = Md5Checksum(filesZip)
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.1/changed.zip"] = changedZip,
|
||||
["https://s3.test/1.4.1/Files.zip"] = filesZip
|
||||
}));
|
||||
var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient));
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.1/PLONDS.json", 100), manifest),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode);
|
||||
Assert.Equal("full payload", File.ReadAllText(Path.Combine(result.Package!.FilesDirectory!, "app.dll")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenDeltaUrlMissing_PreparesFullPackage()
|
||||
{
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.2",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: null,
|
||||
filesUrl: "https://s3.test/1.4.2/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["Files.zip"] = Md5Checksum(filesZip)
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.2/Files.zip"] = filesZip
|
||||
}));
|
||||
var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient));
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.2/PLONDS.json", 100), manifest),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadPlanner_WhenFullChecksumFails_ReturnsUiFailure()
|
||||
{
|
||||
var changedZip = CreateZip(("app.dll", "delta payload"));
|
||||
var filesZip = CreateZip(("app.dll", "full payload"));
|
||||
var manifest = CreateManifest(
|
||||
"1.4.3",
|
||||
downloads: CreateDownloads(
|
||||
changedUrl: "https://s3.test/1.4.3/changed.zip",
|
||||
filesUrl: "https://s3.test/1.4.3/Files.zip"),
|
||||
checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = "md5:00000000000000000000000000000000",
|
||||
["Files.zip"] = "md5:11111111111111111111111111111111"
|
||||
});
|
||||
|
||||
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
|
||||
{
|
||||
["https://s3.test/1.4.3/changed.zip"] = changedZip,
|
||||
["https://s3.test/1.4.3/Files.zip"] = filesZip
|
||||
}));
|
||||
var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient));
|
||||
|
||||
var result = await planner.PrepareAsync(
|
||||
new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.3/PLONDS.json", 100), manifest),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.RequiresUiHandling);
|
||||
Assert.Contains("full package fallback also failed", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreparedPackageInstaller_AppliesDeltaPackageWithoutUpdateDownloadSystem()
|
||||
{
|
||||
var launcherRoot = Path.Combine(_tempRoot, "launcher");
|
||||
var currentDeployment = Path.Combine(launcherRoot, "app-1.0.0-0");
|
||||
Directory.CreateDirectory(currentDeployment);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, ".current"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "LanMountainDesktop.exe"), "exe");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "app.dll"), "old");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "keep.txt"), "keep");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "delete.txt"), "delete");
|
||||
|
||||
var changedDirectory = Path.Combine(_tempRoot, "changed");
|
||||
Directory.CreateDirectory(changedDirectory);
|
||||
File.WriteAllText(Path.Combine(changedDirectory, "app.dll"), "new");
|
||||
var manifestPath = Path.Combine(_tempRoot, "PLONDS.json");
|
||||
await File.WriteAllTextAsync(manifestPath, $$"""
|
||||
{
|
||||
"formatVersion": "2.0",
|
||||
"currentVersion": "1.1.0",
|
||||
"previousVersion": "1.0.0",
|
||||
"isFullUpdate": false,
|
||||
"requiresCleanInstall": false,
|
||||
"channel": "stable",
|
||||
"platform": "windows-x64",
|
||||
"updatedAt": "2026-06-01T00:00:00Z",
|
||||
"filesMap": {
|
||||
"LanMountainDesktop.exe": { "action": "reuse", "hash": "{{Sha256Text("exe")}}", "size": 3 },
|
||||
"app.dll": { "action": "replace", "hash": "{{Sha256Text("new")}}", "size": 3 },
|
||||
"keep.txt": { "action": "reuse", "hash": "{{Sha256Text("keep")}}", "size": 4 },
|
||||
"delete.txt": { "action": "delete", "hash": "", "size": 0 }
|
||||
},
|
||||
"changedFilesMap": {
|
||||
"app.dll": { "archivePath": "app.dll", "hash": "{{Sha256Text("new")}}", "size": 3 }
|
||||
},
|
||||
"checksums": {}
|
||||
}
|
||||
""");
|
||||
var package = new PlondsPreparedPackage(
|
||||
new Version(1, 1, 0),
|
||||
PlondsPackageMode.Delta,
|
||||
manifestPath,
|
||||
Path.Combine(_tempRoot, "changed.zip"),
|
||||
changedDirectory,
|
||||
null,
|
||||
null);
|
||||
|
||||
var result = await new PlondsPreparedPackageInstaller().InstallAsync(
|
||||
package,
|
||||
launcherRoot,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var target = Assert.Single(Directory.GetDirectories(launcherRoot, "app-1.1.0-*"));
|
||||
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "app.dll")));
|
||||
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
|
||||
Assert.False(File.Exists(Path.Combine(target, "delete.txt")));
|
||||
Assert.True(File.Exists(Path.Combine(target, ".current")));
|
||||
Assert.True(File.Exists(Path.Combine(currentDeployment, ".destroy")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreparedPackageInstaller_InstallsFullPackageFromCompleteRootLayout()
|
||||
{
|
||||
var launcherRoot = Path.Combine(_tempRoot, "launcher-full");
|
||||
var currentDeployment = Path.Combine(launcherRoot, "app-1.0.0-0");
|
||||
Directory.CreateDirectory(currentDeployment);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, ".current"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "LanMountainDesktop.exe"), "old-exe");
|
||||
File.WriteAllText(Path.Combine(currentDeployment, "app.dll"), "old");
|
||||
|
||||
var filesRoot = Path.Combine(_tempRoot, "Files-root");
|
||||
var fullAppDirectory = Path.Combine(filesRoot, "app-1.2.0");
|
||||
Directory.CreateDirectory(fullAppDirectory);
|
||||
File.WriteAllText(Path.Combine(filesRoot, "LanMountainDesktop.Launcher.exe"), "launcher");
|
||||
File.WriteAllText(Path.Combine(filesRoot, "LanMountainDesktop.AirAppRuntime.exe"), "runtime");
|
||||
File.WriteAllText(Path.Combine(fullAppDirectory, ".current"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(fullAppDirectory, "LanMountainDesktop.exe"), "new-exe");
|
||||
File.WriteAllText(Path.Combine(fullAppDirectory, "app.dll"), "new");
|
||||
|
||||
var package = new PlondsPreparedPackage(
|
||||
new Version(1, 2, 0),
|
||||
PlondsPackageMode.Full,
|
||||
Path.Combine(_tempRoot, "PLONDS.json"),
|
||||
null,
|
||||
null,
|
||||
Path.Combine(_tempRoot, "Files.zip"),
|
||||
filesRoot);
|
||||
|
||||
var result = await new PlondsPreparedPackageInstaller().InstallAsync(
|
||||
package,
|
||||
launcherRoot,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var target = Assert.Single(Directory.GetDirectories(launcherRoot, "app-1.2.0-*"));
|
||||
Assert.Equal("new-exe", File.ReadAllText(Path.Combine(target, "LanMountainDesktop.exe")));
|
||||
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "app.dll")));
|
||||
Assert.False(File.Exists(Path.Combine(target, "LanMountainDesktop.Launcher.exe")));
|
||||
Assert.True(File.Exists(Path.Combine(target, ".current")));
|
||||
Assert.True(File.Exists(Path.Combine(currentDeployment, ".destroy")));
|
||||
}
|
||||
|
||||
private static PlondsClientManifest CreateManifest(
|
||||
string version,
|
||||
IReadOnlyList<PlondsSourceDescriptor>? sources = null,
|
||||
PlondsClientDownloads? downloads = null,
|
||||
IReadOnlyDictionary<string, string>? checksums = null,
|
||||
bool requiresCleanInstall = false)
|
||||
{
|
||||
return new PlondsClientManifest(
|
||||
FormatVersion: "2.0",
|
||||
CurrentVersion: version,
|
||||
PreviousVersion: "1.0.0",
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
Channel: "stable",
|
||||
Platform: "windows-x64",
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),
|
||||
FilesMap: new Dictionary<string, PlondsClientFileEntry>(),
|
||||
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
|
||||
Checksums: checksums ?? new Dictionary<string, string>(),
|
||||
Downloads: downloads,
|
||||
Sources: sources ?? []);
|
||||
}
|
||||
|
||||
private PlondsHttpPackageDownloader CreateHttpDownloader(HttpClient httpClient)
|
||||
{
|
||||
return new PlondsHttpPackageDownloader(
|
||||
httpClient,
|
||||
new PlondsPackageStore(_tempRoot),
|
||||
new PlondsVerifier());
|
||||
}
|
||||
|
||||
private static PlondsClientDownloads CreateDownloads(string? changedUrl, string? filesUrl)
|
||||
{
|
||||
return new PlondsClientDownloads(
|
||||
GitHub: null,
|
||||
S3: new PlondsS3Downloads(
|
||||
Bucket: "bucket",
|
||||
Prefix: "lanmountain/update/plonds/1.4.0",
|
||||
ManifestKey: "lanmountain/update/plonds/1.4.0/PLONDS.json",
|
||||
ManifestUrl: "https://s3.test/1.4.0/PLONDS.json",
|
||||
ChangedZipKey: changedUrl is null ? null : "lanmountain/update/plonds/1.4.0/changed.zip",
|
||||
ChangedZipUrl: changedUrl,
|
||||
ChangedFolderKey: null,
|
||||
ChangedFolderUrl: null,
|
||||
FilesZipKey: filesUrl is null ? null : "lanmountain/update/plonds/1.4.0/Files.zip",
|
||||
FilesZipUrl: filesUrl,
|
||||
FilesFolderKey: null,
|
||||
FilesFolderUrl: null));
|
||||
}
|
||||
|
||||
private static byte[] CreateZip(params (string Path, string Contents)[] entries)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var (path, contents) in entries)
|
||||
{
|
||||
var entry = archive.CreateEntry(path);
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write(contents);
|
||||
}
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static string Md5Checksum(byte[] bytes)
|
||||
{
|
||||
return $"md5:{Convert.ToHexString(MD5.HashData(bytes)).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string Sha256Text(string text)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ManifestJson(string version, string extraFields = "")
|
||||
{
|
||||
var separator = string.IsNullOrWhiteSpace(extraFields) ? string.Empty : ",";
|
||||
return $$"""
|
||||
{
|
||||
"formatVersion": "2.0",
|
||||
"currentVersion": "{{version}}",
|
||||
"previousVersion": "1.0.0",
|
||||
"isFullUpdate": false,
|
||||
"requiresCleanInstall": false,
|
||||
"channel": "stable",
|
||||
"platform": "windows-x64",
|
||||
"updatedAt": "2026-06-01T00:00:00Z",
|
||||
"filesMap": {},
|
||||
"changedFilesMap": {},
|
||||
"checksums": {}{{separator}}
|
||||
{{extraFields}}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class FakeDownloader(bool deltaFails, bool fullFails) : IPlondsPackageDownloader
|
||||
{
|
||||
public int DeltaCalls { get; private set; }
|
||||
public int FullCalls { get; private set; }
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeltaCalls++;
|
||||
if (deltaFails)
|
||||
{
|
||||
throw new InvalidOperationException("delta failed");
|
||||
}
|
||||
|
||||
return Task.FromResult(CreatePackage(manifest, PlondsPackageMode.Delta));
|
||||
}
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FullCalls++;
|
||||
if (fullFails)
|
||||
{
|
||||
throw new InvalidOperationException("full failed");
|
||||
}
|
||||
|
||||
return Task.FromResult(CreatePackage(manifest, PlondsPackageMode.Full));
|
||||
}
|
||||
|
||||
private static PlondsPreparedPackage CreatePackage(PlondsClientManifest manifest, PlondsPackageMode mode)
|
||||
{
|
||||
PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version);
|
||||
return new PlondsPreparedPackage(
|
||||
version,
|
||||
mode,
|
||||
"PLONDS.json",
|
||||
mode is PlondsPackageMode.Delta ? "changed.zip" : null,
|
||||
mode is PlondsPackageMode.Delta ? "changed" : null,
|
||||
mode is PlondsPackageMode.Full ? "Files.zip" : null,
|
||||
mode is PlondsPackageMode.Full ? "Files" : null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SourceAwareFakeDownloader(string failingSourceId) : IPlondsPackageDownloader
|
||||
{
|
||||
public int DeltaCalls { get; private set; }
|
||||
public string? SuccessfulSourceId { get; private set; }
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeltaCalls++;
|
||||
if (string.Equals(source.Id, failingSourceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("source failed");
|
||||
}
|
||||
|
||||
SuccessfulSourceId = source.Id;
|
||||
return Task.FromResult(CreatePackage(manifest, source, PlondsPackageMode.Delta));
|
||||
}
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(source.Id, failingSourceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("source full failed");
|
||||
}
|
||||
|
||||
SuccessfulSourceId = source.Id;
|
||||
return Task.FromResult(CreatePackage(manifest, source, PlondsPackageMode.Full));
|
||||
}
|
||||
|
||||
private static PlondsPreparedPackage CreatePackage(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode)
|
||||
{
|
||||
PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version);
|
||||
return new PlondsPreparedPackage(
|
||||
version,
|
||||
mode,
|
||||
$"{source.Id}/PLONDS.json",
|
||||
mode is PlondsPackageMode.Delta ? $"{source.Id}/changed.zip" : null,
|
||||
mode is PlondsPackageMode.Delta ? $"{source.Id}/changed" : null,
|
||||
mode is PlondsPackageMode.Full ? $"{source.Id}/Files.zip" : null,
|
||||
mode is PlondsPackageMode.Full ? $"{source.Id}/Files" : null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManifestHandler(
|
||||
IReadOnlyDictionary<string, string> manifests,
|
||||
IReadOnlySet<string>? throwingUrls = null) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.ToString() ?? string.Empty;
|
||||
if (throwingUrls?.Contains(url) == true)
|
||||
{
|
||||
throw new HttpRequestException("manifest source failed");
|
||||
}
|
||||
|
||||
if (!manifests.TryGetValue(url, out var json))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AssetHandler(IReadOnlyDictionary<string, byte[]> assets) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.ToString() ?? string.Empty;
|
||||
if (!assets.TryGetValue(url, out var bytes))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(bytes)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using LanMountainDesktop.Launcher.Plugins;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_AllowsConfiguredPortableDataRootOutsideUserScope()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var appRoot = Path.Combine(_tempRoot, "PackageRoot");
|
||||
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||
Directory.CreateDirectory(launcherDataRoot);
|
||||
File.WriteAllText(
|
||||
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
DataLocationMode = "Portable",
|
||||
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||
PortableDataPath = portableDataRoot
|
||||
}));
|
||||
|
||||
var packagePath = Path.Combine(_tempRoot, "portable.laapp");
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.portable.sample", "Portable Plugin");
|
||||
|
||||
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
Assert.StartsWith(Path.GetFullPath(portableDataRoot), Path.GetFullPath(result.InstalledPackagePath!), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||
{
|
||||
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||
@@ -103,18 +140,24 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
""");
|
||||
}
|
||||
|
||||
private static string CreateUserScopedPluginsDirectory()
|
||||
private string CreateConfiguredPortablePluginsDirectory(out string appRoot)
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Tests",
|
||||
nameof(PluginInstallerServiceTests),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
|
||||
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||
Directory.CreateDirectory(launcherDataRoot);
|
||||
File.WriteAllText(
|
||||
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
DataLocationMode = "Portable",
|
||||
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||
PortableDataPath = portableDataRoot
|
||||
}));
|
||||
|
||||
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||
Directory.CreateDirectory(pluginsDirectory);
|
||||
return pluginsDirectory;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
40
LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs
Normal file
40
LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
93
LanMountainDesktop.Tests/SettingsWindowShellVisualTests.cs
Normal file
93
LanMountainDesktop.Tests/SettingsWindowShellVisualTests.cs
Normal 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)}'.");
|
||||
}
|
||||
}
|
||||
103
LanMountainDesktop.Tests/SystemChromeModeTests.cs
Normal file
103
LanMountainDesktop.Tests/SystemChromeModeTests.cs
Normal 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Plonds;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
@@ -103,35 +104,111 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsUpdateManifestProvider_UsesSelectedUpdateSource()
|
||||
public async Task UpdateSettingsService_WhenPlondsSelected_UsesPlondsServiceWithoutCreatingOrchestrator()
|
||||
{
|
||||
var update = new FakeUpdateSettingsService
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
State = DefaultUpdateState() with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub }
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
|
||||
}
|
||||
};
|
||||
var plonds = new FakeManifestProvider("plonds");
|
||||
var github = new FakeManifestProvider("github");
|
||||
var provider = new SettingsUpdateManifestProvider(new FakeSettingsFacade(update), plonds, github);
|
||||
var plonds = new FakePlondsService
|
||||
{
|
||||
LatestResult = PlondsLatestResult.Available(
|
||||
new Version(1, 0, 0),
|
||||
new Version(9, 9, 9),
|
||||
[new PlondsManifestCandidate(
|
||||
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
CreatePlondsManifest("9.9.9"))])
|
||||
};
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS.");
|
||||
},
|
||||
plondsService: plonds);
|
||||
|
||||
var manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("github", manifest?.DistributionId);
|
||||
Assert.Equal(0, plonds.GetLatestCalls);
|
||||
Assert.Equal(1, github.GetLatestCalls);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
Assert.Equal("9.9.9", report.LatestVersion);
|
||||
Assert.Equal(1, plonds.FindLatestCalls);
|
||||
Assert.False(orchestratorCreated);
|
||||
}
|
||||
|
||||
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds };
|
||||
manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
[Fact]
|
||||
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
|
||||
{
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
|
||||
}
|
||||
};
|
||||
var plonds = new FakePlondsService
|
||||
{
|
||||
LatestResult = PlondsLatestResult.Available(
|
||||
new Version(1, 0, 0),
|
||||
new Version(9, 9, 9),
|
||||
[new PlondsManifestCandidate(
|
||||
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
CreatePlondsManifest("9.9.9", requiresCleanInstall: true))])
|
||||
};
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS check.");
|
||||
},
|
||||
plondsService: plonds);
|
||||
|
||||
Assert.Equal("plonds", manifest?.DistributionId);
|
||||
Assert.Equal(1, plonds.GetLatestCalls);
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
Assert.Equal(UpdatePayloadKind.FullInstaller, report.PayloadKind);
|
||||
Assert.Equal("9.9.9", report.LatestVersion);
|
||||
Assert.False(orchestratorCreated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator()
|
||||
{
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
}
|
||||
};
|
||||
var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
});
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
return orchestrator;
|
||||
},
|
||||
plondsService: new FakePlondsService());
|
||||
|
||||
var _ = service.CurrentPhase;
|
||||
|
||||
Assert.False(orchestratorCreated);
|
||||
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(orchestratorCreated);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -177,6 +254,33 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
LastUpdateCheckUtcMs: null,
|
||||
PendingUpdateSha256: null);
|
||||
|
||||
private static UpdateOrchestrator CreateTestOrchestrator(SettingsUpdateState state)
|
||||
{
|
||||
return new UpdateOrchestrator(
|
||||
new FakeManifestProvider("github"),
|
||||
new UpdateDownloadEngine(new FakeManifestProvider("github"), new ResumableDownloadService(new HttpClient(new EmptyHandler()))),
|
||||
new UpdateInstallGateway(),
|
||||
new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state })));
|
||||
}
|
||||
|
||||
private static PlondsClientManifest CreatePlondsManifest(string version, bool requiresCleanInstall = false)
|
||||
{
|
||||
return new PlondsClientManifest(
|
||||
FormatVersion: "2.0",
|
||||
CurrentVersion: version,
|
||||
PreviousVersion: "1.0.0",
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
Channel: "stable",
|
||||
Platform: "windows-x64",
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),
|
||||
FilesMap: new Dictionary<string, PlondsClientFileEntry>(),
|
||||
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
|
||||
Checksums: new Dictionary<string, string>(),
|
||||
Downloads: null,
|
||||
Sources: []);
|
||||
}
|
||||
|
||||
private sealed class FakeUpdateSettingsService : IUpdateSettingsService
|
||||
{
|
||||
public SettingsUpdateState State { get; set; } = DefaultUpdateState();
|
||||
@@ -263,9 +367,6 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
|
||||
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
public Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PlondsUpdatePayload?>(null);
|
||||
|
||||
public Task<LanMountainDesktop.Services.UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -285,6 +386,115 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
=> Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false));
|
||||
}
|
||||
|
||||
private sealed class FakePlondsService : IPlondsService
|
||||
{
|
||||
public PlondsLatestResult LatestResult { get; set; } = PlondsLatestResult.UpToDate(new Version(1, 0, 0), new Version(1, 0, 0));
|
||||
public PlondsPrepareResult PrepareResult { get; set; } = PlondsPrepareResult.FailedForUi("not prepared");
|
||||
public int FindLatestCalls { get; private set; }
|
||||
public int PrepareLatestCalls { get; private set; }
|
||||
|
||||
public Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
FindLatestCalls++;
|
||||
return Task.FromResult(LatestResult);
|
||||
}
|
||||
|
||||
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareLatestCalls++;
|
||||
return Task.FromResult(PrepareResult);
|
||||
}
|
||||
|
||||
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareLatestCalls++;
|
||||
return Task.FromResult(PrepareResult);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsService : ISettingsService
|
||||
{
|
||||
public event EventHandler<SettingsChangedEvent>? Changed;
|
||||
|
||||
public AppSettingsSnapshot Snapshot { get; init; } = new();
|
||||
|
||||
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
|
||||
{
|
||||
if (typeof(T) == typeof(AppSettingsSnapshot))
|
||||
{
|
||||
return (T)(object)Snapshot.Clone();
|
||||
}
|
||||
|
||||
return new T();
|
||||
}
|
||||
|
||||
public void SaveSnapshot<T>(
|
||||
SettingsScope scope,
|
||||
T snapshot,
|
||||
string? subjectId = null,
|
||||
string? placementId = null,
|
||||
string? sectionId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
if (snapshot is AppSettingsSnapshot appSettings)
|
||||
{
|
||||
CopyUpdateSettings(appSettings, Snapshot);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
|
||||
}
|
||||
|
||||
public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new()
|
||||
=> new();
|
||||
|
||||
public void SaveSection<T>(
|
||||
SettingsScope scope,
|
||||
string subjectId,
|
||||
string sectionId,
|
||||
T section,
|
||||
string? placementId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
|
||||
{
|
||||
}
|
||||
|
||||
public T? GetValue<T>(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null)
|
||||
=> default;
|
||||
|
||||
public void SetValue<T>(
|
||||
SettingsScope scope,
|
||||
string key,
|
||||
T value,
|
||||
string? subjectId = null,
|
||||
string? placementId = null,
|
||||
string? sectionId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
private static void CopyUpdateSettings(AppSettingsSnapshot source, AppSettingsSnapshot target)
|
||||
{
|
||||
target.IncludePrereleaseUpdates = source.IncludePrereleaseUpdates;
|
||||
target.UpdateChannel = source.UpdateChannel;
|
||||
target.UpdateMode = source.UpdateMode;
|
||||
target.UpdateDownloadSource = source.UpdateDownloadSource;
|
||||
target.UpdateDownloadThreads = source.UpdateDownloadThreads;
|
||||
target.ForceUpdateReinstall = source.ForceUpdateReinstall;
|
||||
target.UseGhProxyMirror = source.UseGhProxyMirror;
|
||||
target.PendingUpdateInstallerPath = source.PendingUpdateInstallerPath;
|
||||
target.PendingUpdateVersion = source.PendingUpdateVersion;
|
||||
target.PendingUpdatePublishedAtUtcMs = source.PendingUpdatePublishedAtUtcMs;
|
||||
target.LastUpdateCheckUtcMs = source.LastUpdateCheckUtcMs;
|
||||
target.PendingUpdateSha256 = source.PendingUpdateSha256;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider
|
||||
{
|
||||
public string ProviderName { get; } = providerName;
|
||||
@@ -318,6 +528,14 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
private sealed class EmptyHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService
|
||||
{
|
||||
public ISettingsService Settings => throw new NotSupportedException();
|
||||
|
||||
@@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests
|
||||
{
|
||||
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
||||
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "AirApp", "IAirAppProcessStarter.cs");
|
||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "IAirAppProcessStarter.cs");
|
||||
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
||||
|
||||
Assert.Contains("DataRoot", optionsSource);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj" />
|
||||
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||
|
||||
@@ -394,8 +394,6 @@
|
||||
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
|
||||
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
|
||||
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
|
||||
"settings.appearance.corner_radius.label": "Global corner radius style",
|
||||
"settings.appearance.corner_radius.description": "Select a fixed corner radius style inspired by Xiaomi HyperOS.",
|
||||
"component.color_scheme.follow_system": "Follow system color scheme",
|
||||
"component.color_scheme.native": "Use component custom color scheme",
|
||||
"component.settings.color_scheme": "Color Scheme",
|
||||
@@ -406,7 +404,7 @@
|
||||
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
|
||||
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
|
||||
"settings.appearance.system_material_desc.auto": "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable.",
|
||||
"settings.appearance.restart_message": "Theme source and system material changes require restarting the app.",
|
||||
"settings.appearance.restart_message": "Window chrome changes require restarting the app.",
|
||||
"settings.appearance.preview.primary": "Primary",
|
||||
"settings.appearance.preview.secondary": "Secondary",
|
||||
"settings.appearance.preview.tertiary": "Tertiary",
|
||||
@@ -442,6 +440,7 @@
|
||||
"settings.material_color.wallpaper_seed.label": "Seed",
|
||||
"settings.material_color.system_material.label": "System material",
|
||||
"settings.material_color.system_material.description": "Apply the selected material mode to windows and host surfaces.",
|
||||
"settings.material_color.system_material.restart_message": "System material changes require restarting the app.",
|
||||
"settings.material_color.native_events.label": "Native wallpaper change events",
|
||||
"settings.material_color.native_events.description": "Use OS wallpaper notifications first and keep polling as fallback.",
|
||||
"settings.material_color.native_events.active": "Native wallpaper events active",
|
||||
|
||||
@@ -394,8 +394,6 @@
|
||||
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
||||
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
||||
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
||||
"settings.appearance.corner_radius.label": "全局圆角样式",
|
||||
"settings.appearance.corner_radius.description": "选择固定的全局圆角样式,受 HyperOS 启发。",
|
||||
"component.color_scheme.follow_system": "跟随系统配色",
|
||||
"component.color_scheme.native": "使用组件自定义配色",
|
||||
"component.settings.color_scheme": "配色方案",
|
||||
@@ -406,7 +404,7 @@
|
||||
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
|
||||
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
|
||||
"settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica,在 Windows 10 优先使用 Acrylic,不可用时回退到无材质。",
|
||||
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
|
||||
"settings.appearance.restart_message": "窗口边框模式更改需要重启应用。",
|
||||
"settings.appearance.preview.primary": "主色",
|
||||
"settings.appearance.preview.secondary": "次色",
|
||||
"settings.appearance.preview.tertiary": "三次色",
|
||||
@@ -442,6 +440,7 @@
|
||||
"settings.material_color.wallpaper_seed.label": "种子色",
|
||||
"settings.material_color.system_material.label": "系统材质",
|
||||
"settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。",
|
||||
"settings.material_color.system_material.restart_message": "系统材质更改需要重启应用。",
|
||||
"settings.material_color.native_events.label": "原生壁纸变更事件",
|
||||
"settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。",
|
||||
"settings.material_color.native_events.active": "原生壁纸事件已激活",
|
||||
|
||||
@@ -22,7 +22,7 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
public const string WorldClockAppId = "world-clock";
|
||||
public const string WhiteboardAppId = "whiteboard";
|
||||
|
||||
private const int LauncherIpcRetryCount = 4;
|
||||
private const int RuntimeIpcRetryCount = 4;
|
||||
|
||||
public void OpenWorldClock(string? sourcePlacementId)
|
||||
{
|
||||
@@ -82,27 +82,27 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
|
||||
if (result.Accepted)
|
||||
{
|
||||
AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
|
||||
AppLogger.Info("AirAppLauncher", $"AirApp Runtime accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
|
||||
AppLogger.Warn("AirAppLauncher", $"AirApp Runtime rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex);
|
||||
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through AirApp Runtime. AppId='{appId}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AirAppOperationResult> SendOpenRequestAsync(AirAppOpenRequest request)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++)
|
||||
for (var attempt = 1; attempt <= RuntimeIpcRetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false);
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<IAirAppLifecycleService>();
|
||||
return await proxy.OpenAsync(request).ConfigureAwait(false);
|
||||
}
|
||||
@@ -113,9 +113,9 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"AirAppLauncher",
|
||||
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.",
|
||||
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppRuntimePipeName}'. Starting AirApp Runtime.",
|
||||
ex);
|
||||
TryStartLauncher();
|
||||
TryStartRuntime();
|
||||
}
|
||||
|
||||
await Task.Delay(250 * attempt).ConfigureAwait(false);
|
||||
@@ -123,44 +123,52 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.",
|
||||
$"AirApp Runtime IPC is unavailable. Pipe='{IpcConstants.AirAppRuntimePipeName}'.",
|
||||
lastException);
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId)
|
||||
internal static ProcessStartInfo CreateRuntimeStartInfo(string runtimePath, int requesterProcessId, string? appRoot = null, string? dataRoot = null)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
var startInfo = AirAppRuntimeProcessStarter.CreateStartInfo(runtimePath);
|
||||
if (!string.IsNullOrWhiteSpace(appRoot))
|
||||
{
|
||||
FileName = launcherPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
UseShellExecute = false
|
||||
};
|
||||
startInfo.ArgumentList.Add("air-app-broker");
|
||||
startInfo.ArgumentList.Add("--app-root");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(appRoot));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--data-root");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(dataRoot));
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add("--requester-pid");
|
||||
startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void TryStartLauncher()
|
||||
private static void TryStartRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
var appRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
|
||||
var runtimePath = AirAppRuntimePathResolver.ResolveExecutablePath(appRoot, AppContext.BaseDirectory);
|
||||
if (string.IsNullOrWhiteSpace(runtimePath) || !File.Exists(runtimePath))
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found.");
|
||||
AppLogger.Warn("AirAppLauncher", "Unable to start AirApp Runtime for Air APP request: runtime path was not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId);
|
||||
var dataRoot = AirAppRuntimeDataRootResolver.ResolveDataRoot(appRoot);
|
||||
var startInfo = CreateRuntimeStartInfo(runtimePath, Environment.ProcessId, appRoot, dataRoot);
|
||||
_ = Process.Start(startInfo);
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
||||
$"Started AirApp Runtime. Path='{runtimePath}'; Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex);
|
||||
AppLogger.Warn("AirAppLauncher", "Failed to start AirApp Runtime for Air APP request.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ public static class AppDataPathProvider
|
||||
return Path.Combine(GetDataRoot(), "Wallpapers");
|
||||
}
|
||||
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
_overriddenDataRoot = null;
|
||||
}
|
||||
|
||||
private static string? ResolveDataRootFromArgs(string[] args)
|
||||
{
|
||||
const string prefix = "--data-root=";
|
||||
|
||||
237
LanMountainDesktop/Services/ElevatedPluginInstallService.cs
Normal file
237
LanMountainDesktop/Services/ElevatedPluginInstallService.cs
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,20 +34,7 @@ public sealed record UpdateCheckResult(
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage,
|
||||
bool ForceMode = false,
|
||||
PlondsUpdatePayload? PlondsPayload = null);
|
||||
|
||||
public sealed record PlondsUpdatePayload(
|
||||
string DistributionId,
|
||||
string ChannelId,
|
||||
string SubChannel,
|
||||
string? FileMapJson,
|
||||
string? FileMapSignature,
|
||||
string? FileMapJsonUrl,
|
||||
string? FileMapSignatureUrl,
|
||||
string? UpdateArchiveUrl = null,
|
||||
string? UpdateArchiveSha256 = null,
|
||||
long? UpdateArchiveSizeBytes = null);
|
||||
bool ForceMode = false);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
@@ -162,10 +149,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
var preferredAsset = isUpdateAvailable
|
||||
? SelectPreferredInstallerAsset(release.Assets)
|
||||
: null;
|
||||
var plondsPayload = isUpdateAvailable
|
||||
? TryResolvePlondsPayload(release)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: isUpdateAvailable,
|
||||
@@ -173,8 +156,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
PlondsPayload: plondsPayload);
|
||||
ErrorMessage: null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -239,8 +221,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
@@ -249,8 +229,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: true,
|
||||
PlondsPayload: plondsPayload);
|
||||
ForceMode: true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -703,46 +682,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||
{
|
||||
if (release.Assets is null || release.Assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var platformSuffix = GetPlatformAssetSuffix();
|
||||
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||
var channelId = release.IsPrerelease
|
||||
? UpdateSettingsValues.ChannelPreview
|
||||
: UpdateSettingsValues.ChannelStable;
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: platformSuffix,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||
{
|
||||
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
@@ -11,8 +9,6 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
{
|
||||
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
|
||||
|
||||
public bool TryExit(HostApplicationLifecycleRequest? request = null)
|
||||
{
|
||||
App? app = null;
|
||||
@@ -53,11 +49,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasPendingPluginUpgrades())
|
||||
{
|
||||
return TryRestartWithUpgradeHelper(request);
|
||||
}
|
||||
|
||||
return TryRestartDirectly(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -68,61 +59,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasPendingPluginUpgrades()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json");
|
||||
return File.Exists(pendingUpgradesPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart.");
|
||||
|
||||
var helperPath = ResolveUpgradeHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart.");
|
||||
return TryRestartDirectly(request);
|
||||
}
|
||||
|
||||
var pluginsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
|
||||
var launchArgs = startInfo?.Arguments ?? "";
|
||||
|
||||
var helperStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = helperPath,
|
||||
Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"",
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
|
||||
|
||||
Process.Start(helperStartInfo);
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
|
||||
}
|
||||
|
||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
var app = Application.Current as App;
|
||||
@@ -149,8 +85,4 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
|
||||
}
|
||||
|
||||
private static string ResolveUpgradeHelperPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public static class PendingRestartStateService
|
||||
public const string RenderModeReason = "RenderMode";
|
||||
public const string PluginCatalogReason = "PluginCatalog";
|
||||
public const string SettingsWindowReason = "SettingsWindow";
|
||||
public const string SystemMaterialReason = "SystemMaterial";
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal interface IPlondsPackageDownloader
|
||||
{
|
||||
Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
10
LanMountainDesktop/Services/Plonds/IPlondsService.cs
Normal file
10
LanMountainDesktop/Services/Plonds/IPlondsService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal interface IPlondsService
|
||||
{
|
||||
Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken);
|
||||
|
||||
Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken);
|
||||
}
|
||||
25
LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs
Normal file
25
LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsClientDownloads(
|
||||
PlondsGitHubDownloads? GitHub,
|
||||
PlondsS3Downloads? S3);
|
||||
|
||||
internal sealed record PlondsGitHubDownloads(
|
||||
string? ReleaseUrl,
|
||||
string? ManifestUrl,
|
||||
string? ChangedZipUrl,
|
||||
string? FilesZipUrl);
|
||||
|
||||
internal sealed record PlondsS3Downloads(
|
||||
string? Bucket,
|
||||
string? Prefix,
|
||||
string? ManifestKey,
|
||||
string? ManifestUrl,
|
||||
string? ChangedZipKey,
|
||||
string? ChangedZipUrl,
|
||||
string? ChangedFolderKey,
|
||||
string? ChangedFolderUrl,
|
||||
string? FilesZipKey,
|
||||
string? FilesZipUrl,
|
||||
string? FilesFolderKey,
|
||||
string? FilesFolderUrl);
|
||||
28
LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs
Normal file
28
LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsClientManifest(
|
||||
string FormatVersion,
|
||||
string CurrentVersion,
|
||||
string PreviousVersion,
|
||||
bool IsFullUpdate,
|
||||
bool RequiresCleanInstall,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyDictionary<string, PlondsClientFileEntry> FilesMap,
|
||||
IReadOnlyDictionary<string, PlondsClientChangedFileEntry> ChangedFilesMap,
|
||||
IReadOnlyDictionary<string, string> Checksums,
|
||||
PlondsClientDownloads? Downloads,
|
||||
IReadOnlyList<PlondsSourceDescriptor>? Sources);
|
||||
|
||||
internal sealed record PlondsClientFileEntry(
|
||||
string Action,
|
||||
string Hash,
|
||||
long Size,
|
||||
string HashAlgorithm = "sha256");
|
||||
|
||||
internal sealed record PlondsClientChangedFileEntry(
|
||||
string ArchivePath,
|
||||
string Hash,
|
||||
long Size,
|
||||
string HashAlgorithm = "sha256");
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal static class PlondsClientServiceFactory
|
||||
{
|
||||
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
|
||||
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
|
||||
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json";
|
||||
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
|
||||
|
||||
public static IPlondsService CreateDefault(HttpClient? httpClient = null)
|
||||
{
|
||||
var client = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
var dataRoot = Path.Combine(AppDataPathProvider.GetDataRoot(), "PLONDS");
|
||||
var sourceStore = new PlondsSourceStore(Path.Combine(dataRoot, "sources.json"));
|
||||
var registry = new PlondsSourceRegistry(CreateBuiltInSources());
|
||||
foreach (var source in sourceStore.LoadAsync(CancellationToken.None).GetAwaiter().GetResult())
|
||||
{
|
||||
registry.Add(source);
|
||||
}
|
||||
|
||||
var packageStore = new PlondsPackageStore(Path.Combine(dataRoot, "packages"));
|
||||
return new PlondsService(
|
||||
registry,
|
||||
new PlondsManifestClient(client),
|
||||
new PlondsDownloadPlanner(new PlondsHttpPackageDownloader(client, packageStore, new PlondsVerifier())),
|
||||
sourceStore);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<PlondsSourceDescriptor> CreateBuiltInSources()
|
||||
{
|
||||
return
|
||||
[
|
||||
new(
|
||||
Id: "s3",
|
||||
Kind: "s3",
|
||||
ManifestUrl: ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl),
|
||||
Priority: 100),
|
||||
new(
|
||||
Id: "github",
|
||||
Kind: "github",
|
||||
ManifestUrl: ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl),
|
||||
Priority: 50)
|
||||
];
|
||||
}
|
||||
|
||||
private static string ResolveManifestUrl(string environmentVariable, string fallback)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(environmentVariable);
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
}
|
||||
50
LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs
Normal file
50
LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsDownloadPlanner(IPlondsPackageDownloader downloader)
|
||||
{
|
||||
public async Task<PlondsPrepareResult> PrepareAsync(
|
||||
PlondsManifestCandidate candidate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidate);
|
||||
|
||||
if (candidate.Manifest.RequiresCleanInstall)
|
||||
{
|
||||
return PlondsPrepareResult.FailedForUi(
|
||||
"PLONDS manifest requires a clean install. Use the Host Update installer flow instead.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var deltaPackage = await downloader
|
||||
.PrepareDeltaAsync(candidate.Manifest, candidate.Source, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return PlondsPrepareResult.Prepared(deltaPackage);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception deltaError)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPackage = await downloader
|
||||
.PrepareFullAsync(candidate.Manifest, candidate.Source, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return PlondsPrepareResult.Prepared(fullPackage);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception fullError)
|
||||
{
|
||||
return PlondsPrepareResult.FailedForUi(
|
||||
$"PLONDS delta package failed and full package fallback also failed. Delta: {deltaError.Message}; Full: {fullError.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal static class PlondsDownloadUrlResolver
|
||||
{
|
||||
public static IReadOnlyList<Uri> Resolve(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode)
|
||||
{
|
||||
var urls = new List<string?>();
|
||||
var sourceKind = source.Kind.Trim().ToLowerInvariant();
|
||||
|
||||
if (sourceKind is "s3")
|
||||
{
|
||||
AddS3(urls, manifest, mode);
|
||||
}
|
||||
else if (sourceKind is "github")
|
||||
{
|
||||
AddGitHub(urls, manifest, mode);
|
||||
}
|
||||
|
||||
urls.Add(DerivePackageUrl(source.ManifestUrl, mode));
|
||||
AddS3(urls, manifest, mode);
|
||||
AddGitHub(urls, manifest, mode);
|
||||
|
||||
return urls
|
||||
.Where(url => !string.IsNullOrWhiteSpace(url))
|
||||
.Select(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.OfType<Uri>()
|
||||
.Where(uri => uri.Scheme is "http" or "https")
|
||||
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static void AddS3(List<string?> urls, PlondsClientManifest manifest, PlondsPackageMode mode)
|
||||
{
|
||||
urls.Add(mode is PlondsPackageMode.Delta
|
||||
? manifest.Downloads?.S3?.ChangedZipUrl
|
||||
: manifest.Downloads?.S3?.FilesZipUrl);
|
||||
}
|
||||
|
||||
private static void AddGitHub(List<string?> urls, PlondsClientManifest manifest, PlondsPackageMode mode)
|
||||
{
|
||||
urls.Add(mode is PlondsPackageMode.Delta
|
||||
? manifest.Downloads?.GitHub?.ChangedZipUrl
|
||||
: manifest.Downloads?.GitHub?.FilesZipUrl);
|
||||
}
|
||||
|
||||
private static string? DerivePackageUrl(string manifestUrl, PlondsPackageMode mode)
|
||||
{
|
||||
if (!Uri.TryCreate(manifestUrl, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var packageName = mode is PlondsPackageMode.Delta ? "changed.zip" : "Files.zip";
|
||||
var builder = new UriBuilder(uri);
|
||||
var lastSlash = builder.Path.LastIndexOf('/');
|
||||
builder.Path = lastSlash >= 0
|
||||
? $"{builder.Path[..(lastSlash + 1)]}{packageName}"
|
||||
: packageName;
|
||||
builder.Query = string.Empty;
|
||||
builder.Fragment = string.Empty;
|
||||
return builder.Uri.AbsoluteUri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsHttpPackageDownloader(
|
||||
HttpClient httpClient,
|
||||
PlondsPackageStore packageStore,
|
||||
PlondsVerifier verifier) : IPlondsPackageDownloader
|
||||
{
|
||||
public Task<PlondsPreparedPackage> PrepareDeltaAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (manifest.IsFullUpdate || manifest.RequiresCleanInstall)
|
||||
{
|
||||
throw new InvalidOperationException("PLONDS manifest requires a full package.");
|
||||
}
|
||||
|
||||
return PrepareAsync(manifest, source, PlondsPackageMode.Delta, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PlondsPreparedPackage> PrepareFullAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return PrepareAsync(manifest, source, PlondsPackageMode.Full, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PlondsPreparedPackage> PrepareAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var urls = PlondsDownloadUrlResolver.Resolve(manifest, source, mode);
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"PLONDS manifest does not provide a {mode} package URL.");
|
||||
}
|
||||
|
||||
Exception? lastError = null;
|
||||
foreach (var url in urls)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var staging = await packageStore.CreateStagingAsync(manifest, source, mode, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await DownloadToFileAsync(url, staging.PackageZipPath, cancellationToken).ConfigureAwait(false);
|
||||
await verifier.VerifyFileAsync(
|
||||
staging.PackageZipPath,
|
||||
manifest.Checksums,
|
||||
GetChecksumKeys(mode, url),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
packageStore.ExtractPackage(staging.PackageZipPath, staging.ExtractDirectory);
|
||||
return staging.ToPreparedPackage();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Failed to prepare PLONDS {mode} package.", lastError);
|
||||
}
|
||||
|
||||
private async Task DownloadToFileAsync(Uri url, string destinationPath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"PLONDS package download failed: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(destinationPath));
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var partialPath = $"{destinationPath}.partial";
|
||||
try
|
||||
{
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var target = File.Create(partialPath))
|
||||
{
|
||||
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
File.Move(partialPath, destinationPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(partialPath))
|
||||
{
|
||||
File.Delete(partialPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetChecksumKeys(PlondsPackageMode mode, Uri url)
|
||||
{
|
||||
var urlFileName = Path.GetFileName(url.LocalPath);
|
||||
var keys = mode is PlondsPackageMode.Delta
|
||||
? new[] { "changed.zip", urlFileName }
|
||||
: new[] { "Files.zip", "files.zip", "files-windows-x64.zip", urlFileName };
|
||||
|
||||
return keys
|
||||
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsInstallResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
string? ErrorCode = null);
|
||||
28
LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs
Normal file
28
LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsLatestResult(
|
||||
bool Success,
|
||||
bool IsUpdateAvailable,
|
||||
Version CurrentVersion,
|
||||
Version? LatestVersion,
|
||||
IReadOnlyList<PlondsManifestCandidate> Candidates,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public static PlondsLatestResult Available(
|
||||
Version currentVersion,
|
||||
Version latestVersion,
|
||||
IReadOnlyList<PlondsManifestCandidate> candidates)
|
||||
{
|
||||
return new PlondsLatestResult(true, true, currentVersion, latestVersion, candidates, null);
|
||||
}
|
||||
|
||||
public static PlondsLatestResult UpToDate(Version currentVersion, Version latestVersion)
|
||||
{
|
||||
return new PlondsLatestResult(true, false, currentVersion, latestVersion, [], null);
|
||||
}
|
||||
|
||||
public static PlondsLatestResult Failed(Version currentVersion, string message)
|
||||
{
|
||||
return new PlondsLatestResult(false, false, currentVersion, null, [], message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsManifestCandidate(
|
||||
PlondsSourceDescriptor Source,
|
||||
PlondsClientManifest Manifest);
|
||||
27
LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs
Normal file
27
LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsManifestClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<PlondsClientManifest?> GetManifestAsync(PlondsSourceDescriptor source, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<PlondsClientManifest>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
}
|
||||
53
LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs
Normal file
53
LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal static class PlondsManifestSelector
|
||||
{
|
||||
public static PlondsManifestCandidate? SelectHighestVersion(IEnumerable<PlondsManifestCandidate> candidates)
|
||||
{
|
||||
return SelectHighestVersionCandidates(candidates).FirstOrDefault();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<PlondsManifestCandidate> SelectHighestVersionCandidates(IEnumerable<PlondsManifestCandidate> candidates)
|
||||
{
|
||||
var usableCandidates = candidates
|
||||
.Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _))
|
||||
.OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion))
|
||||
.ThenByDescending(candidate => candidate.Source.Priority)
|
||||
.ToArray();
|
||||
|
||||
var highest = usableCandidates.FirstOrDefault();
|
||||
if (highest is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var highestVersion = ParseVersion(highest.Manifest.CurrentVersion);
|
||||
return usableCandidates
|
||||
.Where(candidate => ParseVersion(candidate.Manifest.CurrentVersion).CompareTo(highestVersion) == 0)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static bool TryParseVersion(string? value, out Version version)
|
||||
{
|
||||
version = new Version(0, 0, 0);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = parsed.Revision >= 0
|
||||
? new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build), parsed.Revision)
|
||||
: new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string value)
|
||||
{
|
||||
return TryParseVersion(value, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
}
|
||||
7
LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs
Normal file
7
LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal enum PlondsPackageMode
|
||||
{
|
||||
Delta,
|
||||
Full
|
||||
}
|
||||
155
LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs
Normal file
155
LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed class PlondsPackageStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly string _rootDirectory;
|
||||
|
||||
public PlondsPackageStore(string rootDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory))
|
||||
{
|
||||
throw new ArgumentException("PLONDS package store root is required.", nameof(rootDirectory));
|
||||
}
|
||||
|
||||
_rootDirectory = Path.GetFullPath(rootDirectory);
|
||||
Directory.CreateDirectory(_rootDirectory);
|
||||
}
|
||||
|
||||
public async Task<PlondsPackageStaging> CreateStagingAsync(
|
||||
PlondsClientManifest manifest,
|
||||
PlondsSourceDescriptor source,
|
||||
PlondsPackageMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version))
|
||||
{
|
||||
throw new InvalidDataException($"Invalid PLONDS version: {manifest.CurrentVersion}");
|
||||
}
|
||||
|
||||
var modeDirectoryName = mode is PlondsPackageMode.Delta ? "delta" : "full";
|
||||
var stagingRoot = Path.Combine(
|
||||
_rootDirectory,
|
||||
SanitizePathSegment(version.ToString()),
|
||||
SanitizePathSegment(source.Id),
|
||||
modeDirectoryName);
|
||||
|
||||
EnsureCleanDirectory(stagingRoot);
|
||||
|
||||
var manifestPath = Path.Combine(stagingRoot, "PLONDS.json");
|
||||
await using (var manifestStream = File.Create(manifestPath))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(manifestStream, manifest, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var zipPath = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed.zip" : "Files.zip");
|
||||
var extractDirectory = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed" : "Files");
|
||||
Directory.CreateDirectory(extractDirectory);
|
||||
|
||||
return new PlondsPackageStaging(version, mode, stagingRoot, manifestPath, zipPath, extractDirectory);
|
||||
}
|
||||
|
||||
public void ExtractPackage(string zipPath, string destinationDirectory)
|
||||
{
|
||||
var resolvedDestination = Path.GetFullPath(destinationDirectory);
|
||||
EnsureStorePath(resolvedDestination);
|
||||
EnsureCleanDirectory(resolvedDestination);
|
||||
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var destinationPath = Path.GetFullPath(Path.Combine(resolvedDestination, entry.FullName));
|
||||
EnsureChildPath(resolvedDestination, destinationPath);
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
Directory.CreateDirectory(destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
entry.ExtractToFile(destinationPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureCleanDirectory(string path)
|
||||
{
|
||||
var resolvedPath = Path.GetFullPath(path);
|
||||
EnsureStorePath(resolvedPath);
|
||||
if (Directory.Exists(resolvedPath))
|
||||
{
|
||||
Directory.Delete(resolvedPath, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(resolvedPath);
|
||||
}
|
||||
|
||||
private void EnsureStorePath(string path)
|
||||
{
|
||||
if (!IsSameOrChildPath(_rootDirectory, path))
|
||||
{
|
||||
throw new InvalidOperationException($"PLONDS staging path is outside the package store: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureChildPath(string parent, string child)
|
||||
{
|
||||
if (!IsSameOrChildPath(parent, child))
|
||||
{
|
||||
throw new InvalidDataException($"PLONDS package entry escapes the staging directory: {child}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameOrChildPath(string parent, string child)
|
||||
{
|
||||
var resolvedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var resolvedChild = Path.GetFullPath(child);
|
||||
return string.Equals(resolvedParent, resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)
|
||||
|| resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
|| resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string SanitizePathSegment(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
|
||||
var sanitized = new string(chars).Trim();
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PlondsPackageStaging(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string RootDirectory,
|
||||
string ManifestPath,
|
||||
string PackageZipPath,
|
||||
string ExtractDirectory)
|
||||
{
|
||||
public PlondsPreparedPackage ToPreparedPackage()
|
||||
{
|
||||
return new PlondsPreparedPackage(
|
||||
Version,
|
||||
Mode,
|
||||
ManifestPath,
|
||||
Mode is PlondsPackageMode.Delta ? PackageZipPath : null,
|
||||
Mode is PlondsPackageMode.Delta ? ExtractDirectory : null,
|
||||
Mode is PlondsPackageMode.Full ? PackageZipPath : null,
|
||||
Mode is PlondsPackageMode.Full ? ExtractDirectory : null);
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs
Normal file
12
LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsPrepareResult(
|
||||
bool Success,
|
||||
PlondsPreparedPackage? Package,
|
||||
string? ErrorMessage,
|
||||
bool RequiresUiHandling)
|
||||
{
|
||||
public static PlondsPrepareResult Prepared(PlondsPreparedPackage package) => new(true, package, null, false);
|
||||
|
||||
public static PlondsPrepareResult FailedForUi(string message) => new(false, null, message, true);
|
||||
}
|
||||
10
LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs
Normal file
10
LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.Services.Plonds;
|
||||
|
||||
internal sealed record PlondsPreparedPackage(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string ManifestPath,
|
||||
string? ChangedZipPath,
|
||||
string? ChangedDirectory,
|
||||
string? FilesZipPath,
|
||||
string? FilesDirectory);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user