mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +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:
|
concurrency:
|
||||||
group: plonds-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
group: plonds-publish-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -19,11 +19,18 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
PLONDS_S3_PREFIX: lanmountain/update/plonds
|
||||||
|
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
|
||||||
|
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
|
||||||
|
PLONDS_S3_MULTIPART_THRESHOLD_MB: '8'
|
||||||
|
PLONDS_S3_MULTIPART_PART_SIZE_MB: '5'
|
||||||
|
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: read
|
actions: read
|
||||||
@@ -35,7 +42,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Resolve release tag and channel
|
- name: Resolve release tag
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -53,22 +60,8 @@ jobs:
|
|||||||
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
CHANNEL="preview"
|
|
||||||
else
|
|
||||||
CHANNEL="stable"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
|
||||||
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
|
|
||||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
|
||||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
|
||||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
|
||||||
fi
|
|
||||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
|
||||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
|
||||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
@@ -76,304 +69,70 @@ jobs:
|
|||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
dotnet-quality: preview
|
dotnet-quality: preview
|
||||||
|
|
||||||
- name: Prepare signing key
|
|
||||||
env:
|
|
||||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
|
||||||
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
|
||||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
|
||||||
if [[ -z "$KEY" ]]; then
|
|
||||||
echo "No signing key is configured."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$KEY" > update-private-key.pem
|
|
||||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build PLONDS tool
|
- name: Build PLONDS tool
|
||||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||||
|
|
||||||
- name: Download release assets
|
- name: Download PLONDS release assets
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir -p release-assets
|
rm -rf plonds-assets
|
||||||
gh release download "$RELEASE_TAG" -D release-assets
|
mkdir -p plonds-assets
|
||||||
find release-assets -maxdepth 1 -type f | sort
|
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -p files-windows-x64.zip -D plonds-assets --clobber
|
||||||
|
test -f plonds-assets/changed.zip
|
||||||
|
test -f plonds-assets/PLONDS.json
|
||||||
|
test -f plonds-assets/files-windows-x64.zip
|
||||||
|
jq -e . plonds-assets/PLONDS.json >/dev/null
|
||||||
|
|
||||||
- name: Prepare PLONDS static output
|
- name: Publish PLONDS assets to Rainyun S3
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
|
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||||
|
S3_REGION: ${{ vars.S3_REGION }}
|
||||||
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
|
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
rm -rf plonds-static
|
if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
|
||||||
mkdir -p plonds-static
|
echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
|
||||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
|
||||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
|
|
||||||
fi
|
|
||||||
if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
|
|
||||||
unzip -q release-assets/plonds-static.zip -d plonds-static
|
|
||||||
fi
|
|
||||||
if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
|
|
||||||
echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload release assets to Rainyun S3
|
REGION="${S3_REGION:-us-east-1}"
|
||||||
env:
|
PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
aws --version
|
|
||||||
for file in release-assets/*; do
|
|
||||||
[[ -f "$file" ]] || continue
|
|
||||||
name="$(basename "$file")"
|
|
||||||
if [[ "$name" == "plonds.json" || "$name" == "plonds.json.sig" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
|
||||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
|
|
||||||
if [[ "$existing_sha" == "$sha256" ]]; then
|
|
||||||
echo "Skip existing asset: $name"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" \
|
|
||||||
--body "$file" \
|
|
||||||
--metadata "sha256=$sha256"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload PLONDS static output to Rainyun S3
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
|
|
||||||
plonds-static/ \
|
|
||||||
"s3://$S3_BUCKET/lanmountain/update/" \
|
|
||||||
--only-show-errors
|
|
||||||
|
|
||||||
- name: Mirror installers to Rainyun S3
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version="${RELEASE_TAG#v}"
|
|
||||||
for file in release-assets/*; do
|
|
||||||
[[ -f "$file" ]] || continue
|
|
||||||
name="$(basename "$file")"
|
|
||||||
platform=""
|
|
||||||
case "$name" in
|
|
||||||
*.exe)
|
|
||||||
if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
|
|
||||||
;;
|
|
||||||
*.deb)
|
|
||||||
platform="linux-x64"
|
|
||||||
;;
|
|
||||||
*.dmg)
|
|
||||||
if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
[[ -n "$platform" ]] || continue
|
|
||||||
key="lanmountain/update/installers/${platform}/${version}/${name}"
|
|
||||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" \
|
|
||||||
--body "$file" \
|
|
||||||
--metadata "sha256=$sha256"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Build PLONDS manifest
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p plonds-output
|
|
||||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||||
build-plonds \
|
publish-s3 \
|
||||||
--release-tag "$RELEASE_TAG" \
|
--release-tag "$RELEASE_TAG" \
|
||||||
--assets-dir release-assets \
|
|
||||||
--output-dir plonds-output \
|
|
||||||
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
|
||||||
--repository "${{ github.repository }}" \
|
--repository "${{ github.repository }}" \
|
||||||
--s3-base-url "$S3_BASE_URL"
|
--manifest "$PWD/plonds-assets/PLONDS.json" \
|
||||||
|
--changed-zip "$PWD/plonds-assets/changed.zip" \
|
||||||
|
--files-zip "$PWD/plonds-assets/files-windows-x64.zip" \
|
||||||
|
--work-dir "$PWD/plonds-publish-work" \
|
||||||
|
--s3-prefix "$PLONDS_S3_PREFIX" \
|
||||||
|
--s3-endpoint "$S3_ENDPOINT" \
|
||||||
|
--s3-region "$REGION" \
|
||||||
|
--s3-bucket "$S3_BUCKET" \
|
||||||
|
--s3-access-key "$S3_ACCESS_KEY" \
|
||||||
|
--s3-secret-key "$S3_SECRET_KEY" \
|
||||||
|
--s3-public-base-url "$PUBLIC_BASE" \
|
||||||
|
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
|
||||||
|
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY" \
|
||||||
|
--multipart-threshold-mb "$PLONDS_S3_MULTIPART_THRESHOLD_MB" \
|
||||||
|
--multipart-part-size-mb "$PLONDS_S3_MULTIPART_PART_SIZE_MB" \
|
||||||
|
--multipart-concurrency "$PLONDS_S3_MULTIPART_CONCURRENCY"
|
||||||
|
|
||||||
- name: Validate PLONDS asset references in Rainyun S3
|
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' plonds-output/plonds.json \
|
|
||||||
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
|
|
||||||
| sort -u)
|
|
||||||
|
|
||||||
if [[ -z "$keys" ]]; then
|
- name: Upload enriched PLONDS manifest to GitHub Release
|
||||||
echo "No S3-backed asset URLs found in plonds.json"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS= read -r key; do
|
|
||||||
[[ -n "$key" ]] || continue
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" >/dev/null
|
|
||||||
done <<< "$keys"
|
|
||||||
|
|
||||||
- name: Upload PLONDS manifest to release
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber
|
gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --clobber
|
||||||
|
|
||||||
- name: Upload PLONDS manifest to Rainyun S3 staging
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for file in plonds-output/plonds.json plonds-output/plonds.json.sig; do
|
|
||||||
name="$(basename "$file")"
|
|
||||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
|
||||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" \
|
|
||||||
--body "$file" \
|
|
||||||
--metadata "sha256=$sha256"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Prepare PLONDS channel pointer
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pointer_file="plonds-output/plonds-latest.json"
|
|
||||||
cat > "$pointer_file" <<'JSON'
|
|
||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"channel": "__CHANNEL__",
|
|
||||||
"releaseTag": "__TAG__",
|
|
||||||
"version": "__VERSION__",
|
|
||||||
"updatedAt": "__UPDATED_AT__",
|
|
||||||
"manifest": {
|
|
||||||
"url": "__MANIFEST_URL__",
|
|
||||||
"signatureUrl": "__SIG_URL__"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
manifest_url="${S3_BASE_URL}/plonds.json"
|
|
||||||
sig_url="${S3_BASE_URL}/plonds.json.sig"
|
|
||||||
version="${RELEASE_TAG#v}"
|
|
||||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
|
|
||||||
sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file"
|
|
||||||
sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file"
|
|
||||||
sed -i "s|__VERSION__|${version}|g" "$pointer_file"
|
|
||||||
sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file"
|
|
||||||
sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file"
|
|
||||||
sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file"
|
|
||||||
|
|
||||||
jq -e . "$pointer_file" >/dev/null
|
|
||||||
|
|
||||||
- name: Atomically publish PLONDS channel pointer
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pointer_file="plonds-output/plonds-latest.json"
|
|
||||||
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-latest.json"
|
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$staging_key" \
|
|
||||||
--body "$pointer_file"
|
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$PLONDS_CHANNEL_POINTER_KEY" \
|
|
||||||
--body "$pointer_file"
|
|
||||||
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
|
|
||||||
|
|
||||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
|
|
||||||
|
|
||||||
- name: Verify Rainyun S3 PLONDS output
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
|
||||||
AWS_REGION: ${{ vars.S3_REGION }}
|
|
||||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mapfile -t required < <(
|
|
||||||
{
|
|
||||||
find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
|
|
||||||
find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
|
|
||||||
find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
|
|
||||||
find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
|
|
||||||
find plonds-static/repo/sha256 -type f | sort | head -n 1
|
|
||||||
} | sed '/^$/d'
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "${#required[@]}" -lt 5 ]]; then
|
|
||||||
echo "Not enough PLONDS static files to verify."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for path in "${required[@]}"; do
|
|
||||||
rel="${path#plonds-static/}"
|
|
||||||
key="lanmountain/update/${rel}"
|
|
||||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
|
||||||
--bucket "$S3_BUCKET" \
|
|
||||||
--key "$key" >/dev/null
|
|
||||||
curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
|
|
||||||
done
|
|
||||||
|
|||||||
101
.github/workflows/release.yml
vendored
101
.github/workflows/release.yml
vendored
@@ -185,6 +185,29 @@ jobs:
|
|||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$publishDir = "publish/airapp-runtime-win-$arch"
|
||||||
|
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./$publishDir `
|
||||||
|
--self-contained:false `
|
||||||
|
-r win-$arch `
|
||||||
|
-p:SelfContained=false `
|
||||||
|
-p:PublishAot=false `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:PublishTrimmed=false `
|
||||||
|
-p:PublishReadyToRun=false `
|
||||||
|
-p:DebugType=none `
|
||||||
|
-p:DebugSymbols=false `
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
- name: Publish AirAppHost
|
- name: Publish AirAppHost
|
||||||
run: |
|
run: |
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
@@ -215,6 +238,7 @@ jobs:
|
|||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$publishDir = "publish/windows-$arch"
|
$publishDir = "publish/windows-$arch"
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$newStructure = "publish-launcher/windows-$arch"
|
||||||
|
|
||||||
@@ -226,10 +250,15 @@ jobs:
|
|||||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Test-Path $runtimePublishDir) {
|
||||||
|
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||||
|
|
||||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
@@ -253,6 +282,7 @@ jobs:
|
|||||||
|
|
||||||
$requiredFiles = @(
|
$requiredFiles = @(
|
||||||
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
||||||
|
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
|
||||||
(Join-Path $appDir "LanMountainDesktop.exe"),
|
(Join-Path $appDir "LanMountainDesktop.exe"),
|
||||||
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
||||||
)
|
)
|
||||||
@@ -330,7 +360,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
$payloadRoot = Join-Path $PWD "publish/windows-$arch"
|
||||||
if (-not (Test-Path $payloadRoot)) {
|
if (-not (Test-Path $payloadRoot)) {
|
||||||
Write-Error "Payload root not found: $payloadRoot"
|
Write-Error "Payload root not found: $payloadRoot"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -344,7 +374,7 @@ jobs:
|
|||||||
|
|
||||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||||
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
if ($relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,12 +492,32 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/airapp-runtime-linux-x64 \
|
||||||
|
--self-contained false \
|
||||||
|
-r linux-x64 \
|
||||||
|
-p:SelfContained=false \
|
||||||
|
-p:PublishAot=false \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:PublishTrimmed=false \
|
||||||
|
-p:PublishReadyToRun=false \
|
||||||
|
-p:DebugType=none \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
- name: Restructure for Launcher
|
- name: Restructure for Launcher
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
publishDir="publish/linux-x64"
|
publishDir="publish/linux-x64"
|
||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
launcherDir="publish/launcher-linux-x64"
|
launcherDir="publish/launcher-linux-x64"
|
||||||
|
runtimeDir="publish/airapp-runtime-linux-x64"
|
||||||
|
|
||||||
mkdir -p "$publishDir"
|
mkdir -p "$publishDir"
|
||||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||||
@@ -477,8 +527,13 @@ jobs:
|
|||||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$runtimeDir" ]; then
|
||||||
|
cp -r "$runtimeDir"/* "$publishDir/"
|
||||||
|
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
touch "$publishDir/$appDir/.current"
|
touch "$publishDir/$appDir/.current"
|
||||||
rm -rf "$launcherDir"
|
rm -rf "$launcherDir" "$runtimeDir"
|
||||||
|
|
||||||
- name: Package as DEB
|
- name: Package as DEB
|
||||||
run: |
|
run: |
|
||||||
@@ -637,10 +692,10 @@ jobs:
|
|||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||||
--self-contained \
|
--self-contained:false \
|
||||||
-r osx-${{ matrix.arch }} \
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:SelfContained=false \
|
||||||
-p:PublishSingleFile=false \
|
-p:PublishSingleFile=false \
|
||||||
-p:SelfContained=true \
|
|
||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false \
|
-p:DebugSymbols=false \
|
||||||
-p:SkipAirAppHostBuild=true \
|
-p:SkipAirAppHostBuild=true \
|
||||||
@@ -651,6 +706,36 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
|
||||||
|
--self-contained false \
|
||||||
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:SelfContained=false \
|
||||||
|
-p:PublishAot=false \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:PublishTrimmed=false \
|
||||||
|
-p:PublishReadyToRun=false \
|
||||||
|
-p:DebugType=none \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Optimize and Guard macOS Payload
|
||||||
|
run: |
|
||||||
|
arch="${{ matrix.arch }}"
|
||||||
|
publishDir="publish/macos-${arch}-app"
|
||||||
|
|
||||||
|
pwsh ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 \
|
||||||
|
-PublishDir "$publishDir" \
|
||||||
|
-RuntimeIdentifier "osx-${arch}" \
|
||||||
|
-AssertClean
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Package Payload Zip
|
- name: Package Payload Zip
|
||||||
run: |
|
run: |
|
||||||
release_dir="$PWD/release-assets"
|
release_dir="$PWD/release-assets"
|
||||||
@@ -673,6 +758,7 @@ jobs:
|
|||||||
app_name="LanMountainDesktop"
|
app_name="LanMountainDesktop"
|
||||||
package_name="${app_name}-${version}-macos-${arch}"
|
package_name="${app_name}-${version}-macos-${arch}"
|
||||||
launcherDir="publish/launcher-macos-$arch"
|
launcherDir="publish/launcher-macos-$arch"
|
||||||
|
runtimeDir="publish/airapp-runtime-macos-$arch"
|
||||||
appSourceDir="publish/macos-$arch-app"
|
appSourceDir="publish/macos-$arch-app"
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||||
@@ -685,6 +771,11 @@ jobs:
|
|||||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$runtimeDir" ]; then
|
||||||
|
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
|
||||||
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||||
mkdir -p "${app_name}.app/Contents/Resources"
|
mkdir -p "${app_name}.app/Contents/Resources"
|
||||||
|
|
||||||
|
|||||||
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
|
# Checklist
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||||
|
|
||||||
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
||||||
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
||||||
- [x] `LanMountainDesktop` builds in Debug.
|
- [x] `LanMountainDesktop` builds in Debug.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Launcher Managed Air APP Lifecycle
|
# Launcher Managed Air APP Lifecycle
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||||
|
|
||||||
- [x] Add shared Air APP lifecycle IPC contracts.
|
- [x] Add shared Air APP lifecycle IPC contracts.
|
||||||
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
||||||
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
- [ ] New install shows OOBE once.
|
- [ ] New install shows OOBE once.
|
||||||
- [ ] Same-user reinstall does not show OOBE again.
|
- [ ] Same-user reinstall does not show OOBE again.
|
||||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
- [ ] `plugin-install` does not auto-enter OOBE.
|
||||||
- [ ] Default plugin install does not request UAC.
|
- [ ] Default plugin install does not request UAC.
|
||||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||||
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
|
|||||||
- `launchSource` values are treated as:
|
- `launchSource` values are treated as:
|
||||||
- `normal`
|
- `normal`
|
||||||
- `postinstall`
|
- `postinstall`
|
||||||
- `apply-update`
|
|
||||||
- `plugin-install`
|
- `plugin-install`
|
||||||
- `debug-preview`
|
- `debug-preview`
|
||||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||||
- Allowed elevation paths are limited to:
|
- Allowed elevation paths are limited to:
|
||||||
- the installer itself
|
- the installer itself
|
||||||
- full installer update application
|
- full installer update application
|
||||||
|
|||||||
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 保留两个工作流
|
### 6.1 保留两个工作流
|
||||||
|
|
||||||
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
||||||
- **Publisher**(`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
|
- **Publisher**(`plonds-uploader.yml`):发布器,负责用仓库内 C# S3 客户端上传 `changed.zip`、`PLONDS.json` 和解压后的 `<version>-changed/` 目录,并把 GitHub/S3 下载信息写回 `PLONDS.json`
|
||||||
|
- **Rollback**:独立 rollback 工作流已废弃,不再维护
|
||||||
|
|
||||||
### 6.2 Comparator 改造后步骤
|
### 6.2 Comparator 改造后步骤
|
||||||
|
|
||||||
@@ -297,7 +298,43 @@ jobs:
|
|||||||
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.3 与当前步骤的差异
|
### 6.3 Publisher 改造后步骤
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# plonds-uploader.yml
|
||||||
|
触发: PLONDS Comparator completed / workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- Checkout
|
||||||
|
- 解析 release tag
|
||||||
|
- Setup .NET
|
||||||
|
- 构建 PLONDS Tool
|
||||||
|
- 从 GitHub Release 下载 changed.zip + PLONDS.json
|
||||||
|
- 调用 dotnet run Plonds.Tool -- publish-s3
|
||||||
|
→ 使用仓库内 C# S3 客户端上传,不依赖 aws CLI
|
||||||
|
→ S3 目录布局:
|
||||||
|
<prefix>/<version>/PLONDS.json
|
||||||
|
<prefix>/<version>/changed.zip
|
||||||
|
<prefix>/<version>/<version>-changed/**
|
||||||
|
<prefix>/<version>/Files.zip
|
||||||
|
<prefix>/<version>/<version>-Files/**
|
||||||
|
→ 回写 PLONDS.json downloads 字段:
|
||||||
|
downloads.github.releaseUrl
|
||||||
|
downloads.github.manifestUrl
|
||||||
|
downloads.github.changedZipUrl
|
||||||
|
downloads.github.filesZipUrl
|
||||||
|
downloads.s3.manifestUrl
|
||||||
|
downloads.s3.changedZipUrl
|
||||||
|
downloads.s3.changedFolderUrl
|
||||||
|
downloads.s3.filesZipUrl
|
||||||
|
downloads.s3.filesFolderUrl
|
||||||
|
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 与当前步骤的差异
|
||||||
|
|
||||||
| 当前步骤 | 改造后 |
|
| 当前步骤 | 改造后 |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
@@ -307,6 +344,8 @@ jobs:
|
|||||||
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||||
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||||
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||||
|
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
|
||||||
|
| 独立 rollback workflow | ❌ 删除 |
|
||||||
|
|
||||||
## 7. 双模式差分生成
|
## 7. 双模式差分生成
|
||||||
|
|
||||||
@@ -504,9 +543,7 @@ build-delta-from-commits --platform <platform>
|
|||||||
|
|
||||||
## 8. 不在本次改造范围内的事项
|
## 8. 不在本次改造范围内的事项
|
||||||
|
|
||||||
- Publisher 工作流改造(后续单独设计)
|
|
||||||
- Rollback 工作流改造(后续单独设计)
|
|
||||||
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||||
- Launcher 侧客户端代码改造(后续单独设计)
|
- Launcher 侧客户端代码改造(后续单独设计)
|
||||||
- Plonds.Api 项目处置(后续决定是否保留)
|
- Plonds.Api 项目处置(后续决定是否保留)
|
||||||
- `build-index`、`build-plonds`、`generate`、`publish`、`sign`、`pack-payload` 等 Tool 命令的清理(后续处理)
|
- `build-index`、`generate`、`publish`、`sign` 等旧 Tool 命令的清理(后续处理)
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ Rebuild the settings window as an independent Fluent shell with a custom titleba
|
|||||||
|
|
||||||
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
||||||
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
||||||
|
- Keep the titlebar and content area on one shared full-window background layer; the custom titlebar must remain transparent and must not paint a contrasting strip.
|
||||||
|
- Avoid a visible titlebar bottom divider that makes the titlebar read as a separate color band.
|
||||||
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
||||||
|
- Keep `FANavigationView` pane and content template backgrounds transparent in the settings shell so the navigation control does not reintroduce a second surface color.
|
||||||
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
||||||
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
||||||
- Add `auto` system material mode and make it the default.
|
- Add `auto` system material mode and make it the default.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Make the Settings > Update page the single user-facing control surface for the h
|
|||||||
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
||||||
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
||||||
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
||||||
- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged.
|
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
|
|||||||
|
|
||||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
|
||||||
|
|
||||||
## Migration Note
|
## Migration Note
|
||||||
|
|
||||||
|
|||||||
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
|
internal sealed class AirAppHostLocator
|
||||||
{
|
{
|
||||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
||||||
|
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
|
||||||
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
||||||
|
|
||||||
|
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||||
|
? WindowsExecutableName
|
||||||
|
: UnixExecutableName;
|
||||||
|
|
||||||
public string Resolve(string? packageRoot, string? hostPath = null)
|
public string Resolve(string? packageRoot, string? hostPath = null)
|
||||||
{
|
{
|
||||||
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
||||||
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
|
|||||||
{
|
{
|
||||||
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
||||||
{
|
{
|
||||||
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
|
yield return Path.Combine(root, "AirAppHost", ExecutableName);
|
||||||
yield return Path.Combine(root, "AirAppHost", DllName);
|
yield return Path.Combine(root, "AirAppHost", DllName);
|
||||||
yield return Path.Combine(root, WindowsExecutableName);
|
yield return Path.Combine(root, ExecutableName);
|
||||||
yield return Path.Combine(root, DllName);
|
yield return Path.Combine(root, DllName);
|
||||||
|
|
||||||
if (Directory.Exists(root))
|
if (Directory.Exists(root))
|
||||||
{
|
{
|
||||||
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
||||||
{
|
{
|
||||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
|
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
|
||||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
||||||
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
|
yield return Path.Combine(deploymentDirectory, ExecutableName);
|
||||||
yield return Path.Combine(deploymentDirectory, DllName);
|
yield return Path.Combine(deploymentDirectory, DllName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
|
|||||||
"Release",
|
"Release",
|
||||||
#endif
|
#endif
|
||||||
"net10.0",
|
"net10.0",
|
||||||
WindowsExecutableName);
|
ExecutableName);
|
||||||
|
|
||||||
yield return Path.Combine(
|
yield return Path.Combine(
|
||||||
current.FullName,
|
current.FullName,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal static class AirAppInstanceKey
|
internal static class AirAppInstanceKey
|
||||||
{
|
{
|
||||||
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
|
|||||||
|
|
||||||
private static string Normalize(string? value, string fallback)
|
private static string Normalize(string? value, string fallback)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value)
|
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||||
? fallback
|
|
||||||
: value.Trim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,15 @@ using System.Diagnostics;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly IAirAppProcessStarter _processStarter;
|
private readonly IAirAppProcessStarter _processStarter;
|
||||||
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
|
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||||
{
|
{
|
||||||
_processStarter = processStarter;
|
_processStarter = processStarter;
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
var appId = Normalize(request.AppId, "unknown");
|
var appId = Normalize(request.AppId, "unknown");
|
||||||
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
||||||
Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
request.SourceComponentId,
|
request.SourceComponentId,
|
||||||
request.SourcePlacementId);
|
request.SourcePlacementId);
|
||||||
_instances[instanceKey] = instance;
|
_instances[instanceKey] = instance;
|
||||||
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||||
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||||
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
request.SourceComponentId,
|
request.SourceComponentId,
|
||||||
request.SourcePlacementId);
|
request.SourcePlacementId);
|
||||||
_instances[instanceKey] = instance;
|
_instances[instanceKey] = instance;
|
||||||
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||||
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
(processId <= 0 || instance.ProcessId == processId))
|
(processId <= 0 || instance.ProcessId == processId))
|
||||||
{
|
{
|
||||||
_instances.Remove(instanceKey);
|
_instances.Remove(instanceKey);
|
||||||
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||||
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
foreach (var key in exitedKeys)
|
foreach (var key in exitedKeys)
|
||||||
{
|
{
|
||||||
_instances.Remove(key);
|
_instances.Remove(key);
|
||||||
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsProcessAlive(int processId)
|
internal static bool IsProcessAlive(int processId)
|
||||||
{
|
{
|
||||||
if (processId <= 0)
|
if (processId <= 0)
|
||||||
{
|
{
|
||||||
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
|
|
||||||
private static string Normalize(string? value, string fallback)
|
private static string Normalize(string? value, string fallback)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value)
|
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||||
? fallback
|
|
||||||
: value.Trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int SW_SHOWNORMAL = 1;
|
private const int SW_SHOWNORMAL = 1;
|
||||||
@@ -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;
|
using System.Diagnostics;
|
||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal interface IAirAppProcessStarter
|
internal interface IAirAppProcessStarter
|
||||||
{
|
{
|
||||||
@@ -12,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
private readonly Func<string?> _packageRootProvider;
|
private readonly Func<string?> _packageRootProvider;
|
||||||
private readonly Func<string?> _hostPathProvider;
|
private readonly Func<string?> _hostPathProvider;
|
||||||
private readonly Func<string?> _dataRootProvider;
|
private readonly Func<string?> _dataRootProvider;
|
||||||
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
|
|
||||||
|
|
||||||
public AirAppProcessStarter(
|
public AirAppProcessStarter(
|
||||||
AirAppHostLocator locator,
|
AirAppHostLocator locator,
|
||||||
Func<string?> packageRootProvider,
|
Func<string?> packageRootProvider,
|
||||||
Func<string?> hostPathProvider,
|
Func<string?> hostPathProvider,
|
||||||
Func<string?> dataRootProvider,
|
Func<string?> dataRootProvider)
|
||||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
|
||||||
{
|
{
|
||||||
_locator = locator;
|
_locator = locator;
|
||||||
_packageRootProvider = packageRootProvider;
|
_packageRootProvider = packageRootProvider;
|
||||||
_hostPathProvider = hostPathProvider;
|
_hostPathProvider = hostPathProvider;
|
||||||
_dataRootProvider = dataRootProvider;
|
_dataRootProvider = dataRootProvider;
|
||||||
_runtimeProbeOptions = runtimeProbeOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Process? Start(
|
public Process? Start(
|
||||||
@@ -36,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
string? sourcePlacementId)
|
string? sourcePlacementId)
|
||||||
{
|
{
|
||||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||||
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
|
var startInfo = CreateStartInfo(hostPath);
|
||||||
|
|
||||||
AddArgument(startInfo, "--app-id", appId);
|
AddArgument(startInfo, "--app-id", appId);
|
||||||
AddArgument(startInfo, "--session-id", sessionId);
|
AddArgument(startInfo, "--session-id", sessionId);
|
||||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
|
||||||
var dataRoot = _dataRootProvider();
|
var dataRoot = _dataRootProvider();
|
||||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||||
{
|
{
|
||||||
@@ -58,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||||
var process = Process.Start(startInfo);
|
var process = Process.Start(startInfo);
|
||||||
if (process is not null)
|
if (process is not null)
|
||||||
@@ -68,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,54 +80,11 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
return process;
|
return process;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static ProcessStartInfo CreateStartInfo(
|
internal static ProcessStartInfo CreateStartInfo(string hostPath)
|
||||||
string hostPath,
|
|
||||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
|
||||||
{
|
{
|
||||||
var startInfo = new ProcessStartInfo
|
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
|
||||||
};
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
|
|
||||||
{
|
|
||||||
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
|
||||||
if (!executableRuntime.IsAvailable)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
|
||||||
executableRuntime.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = hostPath;
|
|
||||||
return startInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
|
||||||
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
|
||||||
runtime.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = runtime.DotNetHostPath;
|
|
||||||
startInfo.ArgumentList.Add(hostPath);
|
|
||||||
return startInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = "dotnet";
|
|
||||||
startInfo.ArgumentList.Add(hostPath);
|
|
||||||
return startInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||||
{
|
{
|
||||||
startInfo.ArgumentList.Add(name);
|
startInfo.ArgumentList.Add(name);
|
||||||
@@ -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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.IsAirAppBrokerCommand)
|
|
||||||
{
|
|
||||||
_ = AirAppBrokerEntryHandler.RunAsync(desktop, context);
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.IsDebugMode && !context.IsPreviewCommand)
|
if (context.IsDebugMode && !context.IsPreviewCommand)
|
||||||
{
|
{
|
||||||
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
||||||
|
|||||||
@@ -11,15 +11,7 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
PropertyNameCaseInsensitive = true)]
|
PropertyNameCaseInsensitive = true)]
|
||||||
[JsonSerializable(typeof(SignedFileMap))]
|
|
||||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileMap))]
|
|
||||||
[JsonSerializable(typeof(PlondsComponentEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
|
||||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||||
[JsonSerializable(typeof(InstallCheckpoint))]
|
|
||||||
[JsonSerializable(typeof(AppVersionInfo))]
|
[JsonSerializable(typeof(AppVersionInfo))]
|
||||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||||
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
||||||
@@ -37,11 +29,11 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||||
[JsonSerializable(typeof(PrivacyConfig))]
|
[JsonSerializable(typeof(PrivacyConfig))]
|
||||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
|
||||||
[JsonSerializable(typeof(AirAppOpenRequest))]
|
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||||
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||||
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||||
[JsonSerializable(typeof(AirAppOperationResult))]
|
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||||
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeStatus))]
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
|
|
||||||
internal sealed class CommandContext
|
internal sealed class CommandContext
|
||||||
{
|
{
|
||||||
public const string AirAppBrokerCommand = "air-app-broker";
|
|
||||||
|
|
||||||
private const string LaunchSourceOptionName = "launch-source";
|
private const string LaunchSourceOptionName = "launch-source";
|
||||||
|
|
||||||
private static readonly string[] GuiCommands =
|
private static readonly string[] GuiCommands =
|
||||||
[
|
[
|
||||||
"launch",
|
"launch",
|
||||||
AirAppBrokerCommand,
|
|
||||||
"preview-splash",
|
"preview-splash",
|
||||||
"preview-error",
|
"preview-error",
|
||||||
"preview-update",
|
"preview-update",
|
||||||
@@ -62,15 +59,11 @@ internal sealed class CommandContext
|
|||||||
public bool IsPreviewCommand =>
|
public bool IsPreviewCommand =>
|
||||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsAirAppBrokerCommand =>
|
|
||||||
string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public bool IsGuiCommand =>
|
public bool IsGuiCommand =>
|
||||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsMaintenanceCommand =>
|
public bool IsMaintenanceCommand =>
|
||||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public string? ExplicitAppRoot => GetOption("app-root");
|
public string? ExplicitAppRoot => GetOption("app-root");
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
global using LanMountainDesktop.Launcher.AirApp;
|
|
||||||
global using LanMountainDesktop.Launcher.Deployment;
|
global using LanMountainDesktop.Launcher.Deployment;
|
||||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
global using LanMountainDesktop.Launcher.Ipc;
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal static class Commands
|
|||||||
{
|
{
|
||||||
var source = context.GetOption("source") ?? string.Empty;
|
var source = context.GetOption("source") ?? string.Empty;
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||||
result = installer.InstallPackage(source, pluginsDir);
|
result = installer.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -91,12 +91,12 @@ internal static class Commands
|
|||||||
{
|
{
|
||||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
return pluginInstaller.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
case "update":
|
case "update":
|
||||||
{
|
{
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
|
|||||||
@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
|
|||||||
|
|
||||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||||
{
|
{
|
||||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
var targetDataRoot = mode == DataLocationMode.Portable
|
||||||
? Path.GetFullPath(customPath)
|
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||||
|
? customPath
|
||||||
|
: DefaultPortableDataPath)
|
||||||
: _defaultSystemDataPath;
|
: _defaultSystemDataPath;
|
||||||
|
|
||||||
var config = new DataLocationConfig
|
var config = new DataLocationConfig
|
||||||
|
|||||||
@@ -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">
|
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
||||||
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
|
<PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<MakeDir Directories="$(PublicKeyDestDir)" />
|
<MakeDir Directories="$(PublicKeyDestDir)" />
|
||||||
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
||||||
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
|
<PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
||||||
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
|
|||||||
@@ -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;
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
internal sealed class SignedFileMap
|
|
||||||
{
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? Platform { get; set; }
|
|
||||||
|
|
||||||
public string? Arch { get; set; }
|
|
||||||
|
|
||||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class UpdateFileEntry
|
|
||||||
{
|
|
||||||
public string Path { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? ArchivePath { get; set; }
|
|
||||||
|
|
||||||
public string Action { get; set; } = "replace";
|
|
||||||
|
|
||||||
public string? Sha256 { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class SnapshotMetadata
|
internal sealed class SnapshotMetadata
|
||||||
{
|
{
|
||||||
public string SnapshotId { get; set; } = string.Empty;
|
public string SnapshotId { get; set; } = string.Empty;
|
||||||
@@ -40,124 +16,3 @@ internal sealed class SnapshotMetadata
|
|||||||
|
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class InstallCheckpoint
|
|
||||||
{
|
|
||||||
public string SnapshotId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string SourceVersion { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? TargetVersion { get; set; }
|
|
||||||
|
|
||||||
public string? SourceDirectory { get; set; }
|
|
||||||
|
|
||||||
public string TargetDirectory { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public bool IsInitialDeployment { get; set; }
|
|
||||||
|
|
||||||
public int AppliedCount { get; set; }
|
|
||||||
|
|
||||||
public int VerifiedCount { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class UpdateApplyResult
|
|
||||||
{
|
|
||||||
public bool Success { get; init; }
|
|
||||||
|
|
||||||
public string Message { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string? FromVersion { get; init; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; init; }
|
|
||||||
|
|
||||||
public string? RolledBackTo { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsUpdateMetadata
|
|
||||||
{
|
|
||||||
public string? DistributionId { get; set; }
|
|
||||||
|
|
||||||
public string? Channel { get; set; }
|
|
||||||
|
|
||||||
public string? SubChannel { get; set; }
|
|
||||||
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? FileMapPath { get; set; }
|
|
||||||
|
|
||||||
public string? FileMapSignaturePath { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsFileMap
|
|
||||||
{
|
|
||||||
public string? DistributionId { get; set; }
|
|
||||||
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? Version { get; set; }
|
|
||||||
|
|
||||||
public string? Platform { get; set; }
|
|
||||||
|
|
||||||
public string? Arch { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsComponentEntry> Components { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsComponentEntry
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? Version { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsFileEntry
|
|
||||||
{
|
|
||||||
public string Path { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? Action { get; set; } = "replace";
|
|
||||||
|
|
||||||
public string? Url { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectUrl { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectPath { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectKey { get; set; }
|
|
||||||
|
|
||||||
public string? ArchivePath { get; set; }
|
|
||||||
|
|
||||||
public string? Sha256 { get; set; }
|
|
||||||
|
|
||||||
public string? Sha512 { get; set; }
|
|
||||||
|
|
||||||
public string? Sha512Base64 { get; set; }
|
|
||||||
|
|
||||||
public byte[]? Sha512Bytes { get; set; }
|
|
||||||
|
|
||||||
public PlondsHashDescriptor? Hash { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsHashDescriptor
|
|
||||||
{
|
|
||||||
public string? Algorithm { get; set; }
|
|
||||||
|
|
||||||
public string? Value { get; set; }
|
|
||||||
|
|
||||||
public byte[]? Bytes { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
|
|||||||
TimeSpan.FromMilliseconds(500)
|
TimeSpan.FromMilliseconds(500)
|
||||||
];
|
];
|
||||||
|
|
||||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory, string? appRoot = null)
|
||||||
{
|
{
|
||||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||||
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
|
|||||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
|
||||||
{
|
{
|
||||||
return elevationRequiredResult;
|
return elevationRequiredResult;
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ internal sealed class PluginInstallerService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory, string? appRoot)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
|
|||||||
string? allowedRoot = null;
|
string? allowedRoot = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
|
||||||
var resolver = new DataLocationResolver(appRoot);
|
? Path.GetFullPath(appRoot)
|
||||||
|
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||||
|
var resolver = new DataLocationResolver(resolvedAppRoot);
|
||||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
_installerService = installerService;
|
_installerService = installerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
|
||||||
{
|
{
|
||||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||||
if (!File.Exists(pendingPath))
|
if (!File.Exists(pendingPath))
|
||||||
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
|
||||||
succeeded.Add(item);
|
succeeded.Add(item);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -57,14 +57,6 @@
|
|||||||
"DOTNET_ENVIRONMENT": "Development"
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Launcher (Update Check)": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"commandLineArgs": "update check",
|
|
||||||
"workingDirectory": "$(SolutionDir)",
|
|
||||||
"environmentVariables": {
|
|
||||||
"DOTNET_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Launcher (Plugin Install)": {
|
"Launcher (Plugin Install)": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||||
|
|||||||
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.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||||
@@ -30,52 +28,3 @@ internal static class LaunchEntryHandler
|
|||||||
SplashWindow splashWindow) =>
|
SplashWindow splashWindow) =>
|
||||||
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class AirAppBrokerEntryHandler
|
|
||||||
{
|
|
||||||
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
|
|
||||||
{
|
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
|
||||||
var requesterPid = context.GetIntOption("requester-pid", 0);
|
|
||||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
|
||||||
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
|
|
||||||
|
|
||||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
|
||||||
new LauncherAirAppLifecycleService(
|
|
||||||
new AirAppProcessStarter(
|
|
||||||
new AirAppHostLocator(),
|
|
||||||
() => appRoot,
|
|
||||||
() => null,
|
|
||||||
() => dataLocationResolver.ResolveDataRoot())));
|
|
||||||
airAppIpcHost.Start();
|
|
||||||
|
|
||||||
while (ShouldKeepAlive(requesterPid, airAppIpcHost.LifecycleService))
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Info("Air APP broker exiting.");
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool ShouldKeepAirAppBrokerAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService)
|
|
||||||
{
|
|
||||||
if (requesterPid <= 0)
|
|
||||||
{
|
|
||||||
return lifecycleService.HasLiveAirApps();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var process = System.Diagnostics.Process.GetProcessById(requesterPid);
|
|
||||||
return !process.HasExited || lifecycleService.HasLiveAirApps();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return lifecycleService.HasLiveAirApps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ShouldKeepAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) =>
|
|
||||||
ShouldKeepAirAppBrokerAlive(requesterPid, lifecycleService);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ internal static class LauncherGuiCoordinator
|
|||||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||||
|
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||||
|
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||||
context.LaunchSource,
|
context.LaunchSource,
|
||||||
successPolicy,
|
successPolicy,
|
||||||
@@ -44,15 +47,6 @@ internal static class LauncherGuiCoordinator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
|
||||||
new LauncherAirAppLifecycleService(
|
|
||||||
new AirAppProcessStarter(
|
|
||||||
new AirAppHostLocator(),
|
|
||||||
() => appRoot,
|
|
||||||
() => null,
|
|
||||||
() => dataLocationResolver.ResolveDataRoot())));
|
|
||||||
airAppIpcHost.Start();
|
|
||||||
|
|
||||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||||
coordinatorPipeName,
|
coordinatorPipeName,
|
||||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||||
@@ -129,7 +123,8 @@ internal static class LauncherGuiCoordinator
|
|||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||||
|
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||||
@@ -173,17 +168,15 @@ internal static class LauncherGuiCoordinator
|
|||||||
return fallbackHostPid;
|
return fallbackHostPid;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WaitForManagedProcessesToExitAsync(
|
private static async Task WaitForHostProcessToExitAsync(int hostPid)
|
||||||
int hostPid,
|
|
||||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
|
||||||
{
|
{
|
||||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
|
||||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
while (TryGetLiveProcess(hostPid))
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
Logger.Info("Launcher host background lifetime completed; host process is gone.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||||
|
|||||||
@@ -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 ProtocolVersion = "external-ipc-public-api.v1";
|
||||||
|
|
||||||
public const string AirAppLifecyclePipeName = "LanMountainDesktop.Launcher.AirApp.v1";
|
public const string AirAppRuntimePipeName = "LanMountainDesktop.AirAppRuntime.v1";
|
||||||
|
|
||||||
|
[Obsolete("Use AirAppRuntimePipeName. The lifecycle service is now hosted by LanMountainDesktop.AirAppRuntime.")]
|
||||||
|
public const string AirAppLifecyclePipeName = AirAppRuntimePipeName;
|
||||||
|
|
||||||
public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";
|
public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";
|
||||||
|
|
||||||
|
|||||||
@@ -96,17 +96,17 @@ public sealed class AirAppLauncherServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
|
public void CreateRuntimeStartInfo_UsesAirAppRuntimeAndRequesterPid()
|
||||||
{
|
{
|
||||||
var startInfo = AirAppLauncherService.CreateBrokerStartInfo(
|
var startInfo = AirAppLauncherService.CreateRuntimeStartInfo(
|
||||||
@"C:\Apps\LanMountainDesktop.Launcher.exe",
|
@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe",
|
||||||
12345);
|
12345);
|
||||||
|
|
||||||
Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName);
|
Assert.Equal(@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe", startInfo.FileName);
|
||||||
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
|
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
|
||||||
Assert.False(startInfo.UseShellExecute);
|
Assert.False(startInfo.UseShellExecute);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
["air-app-broker", "--requester-pid", "12345"],
|
["--requester-pid", "12345"],
|
||||||
startInfo.ArgumentList);
|
startInfo.ArgumentList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using LanMountainDesktop.Launcher.AirApp;
|
|
||||||
using LanMountainDesktop.Launcher.Infrastructure;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
@@ -29,38 +27,14 @@ public sealed class AirAppProcessStarterRuntimeTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
|
public void CreateStartInfo_UsesDotnetHost_ForDllFallback()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var programFiles = Path.Combine(_root, "ProgramFiles");
|
|
||||||
var dotnetRoot = Path.Combine(programFiles, "dotnet");
|
|
||||||
Directory.CreateDirectory(dotnetRoot);
|
|
||||||
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
|
|
||||||
File.WriteAllText(dotnetHost, string.Empty);
|
|
||||||
Directory.CreateDirectory(Path.Combine(
|
|
||||||
dotnetRoot,
|
|
||||||
"shared",
|
|
||||||
DotNetRuntimeProbe.RequiredSharedFrameworkName,
|
|
||||||
"10.0.5"));
|
|
||||||
|
|
||||||
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
|
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
|
||||||
File.WriteAllText(hostDll, string.Empty);
|
File.WriteAllText(hostDll, string.Empty);
|
||||||
var options = new DotNetRuntimeProbeOptions
|
|
||||||
{
|
|
||||||
Architecture = DotNetRuntimeArchitecture.X64,
|
|
||||||
ProgramFilesPath = programFiles,
|
|
||||||
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
|
|
||||||
IncludeRegistry = false,
|
|
||||||
IncludeDotNetCli = false
|
|
||||||
};
|
|
||||||
|
|
||||||
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
|
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll);
|
||||||
|
|
||||||
Assert.Equal(dotnetHost, startInfo.FileName);
|
Assert.Contains("dotnet", Path.GetFileName(startInfo.FileName), StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
|
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 System.Diagnostics;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Launcher;
|
|
||||||
using LanMountainDesktop.Launcher.AirApp;
|
|
||||||
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
public sealed class LauncherAirAppLifecycleServiceTests
|
public sealed class AirAppRuntimeLifecycleServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
|
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
|
||||||
{
|
{
|
||||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||||
var service = new LauncherAirAppLifecycleService(starter);
|
var service = new AirAppLifecycleService(starter);
|
||||||
var request = new AirAppOpenRequest(
|
var request = new AirAppOpenRequest(
|
||||||
"whiteboard",
|
"whiteboard",
|
||||||
BuiltInComponentIds.DesktopWhiteboard,
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
@@ -36,7 +33,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
|
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
|
||||||
{
|
{
|
||||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||||
var service = new LauncherAirAppLifecycleService(starter);
|
var service = new AirAppLifecycleService(starter);
|
||||||
|
|
||||||
var first = await service.OpenAsync(new AirAppOpenRequest(
|
var first = await service.OpenAsync(new AirAppOpenRequest(
|
||||||
"world-clock",
|
"world-clock",
|
||||||
@@ -62,7 +59,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
|
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
|
||||||
{
|
{
|
||||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||||
var service = new LauncherAirAppLifecycleService(starter);
|
var service = new AirAppLifecycleService(starter);
|
||||||
var instanceKey = AirAppInstanceKey.Build(
|
var instanceKey = AirAppInstanceKey.Build(
|
||||||
"whiteboard",
|
"whiteboard",
|
||||||
BuiltInComponentIds.DesktopWhiteboard,
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
@@ -92,7 +89,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
|
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
||||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
|
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
|
||||||
|
|
||||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||||
@@ -112,26 +109,35 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive()
|
public void RuntimeLifetime_KeepsAliveWhileRequesterIsAlive()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
|
new AirAppRuntimeOptions(null, null, 0, Environment.ProcessId),
|
||||||
|
service);
|
||||||
|
|
||||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
|
Assert.True(lifetime.ShouldKeepAlive());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain()
|
public void RuntimeLifetime_StopsWhenNoProcessOrAirAppsRemain()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
|
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||||
|
service);
|
||||||
|
|
||||||
Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
Assert.False(lifetime.ShouldKeepAlive());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive()
|
public async Task RuntimeLifetime_KeepsAliveWhileAirAppIsAlive()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
|
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
|
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||||
|
service);
|
||||||
|
|
||||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||||
instanceKey,
|
instanceKey,
|
||||||
@@ -142,28 +148,23 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
BuiltInComponentIds.DesktopWorldClock,
|
BuiltInComponentIds.DesktopWorldClock,
|
||||||
"clock-2"));
|
"clock-2"));
|
||||||
|
|
||||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
Assert.True(lifetime.ShouldKeepAlive());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment()
|
public async Task RuntimeControl_AttachesHostProcess()
|
||||||
{
|
{
|
||||||
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
try
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
{
|
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
|
service);
|
||||||
|
var control = new AirAppRuntimeControlService(lifetime);
|
||||||
|
|
||||||
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
var result = await control.AttachHostAsync(Environment.ProcessId);
|
||||||
|
|
||||||
Assert.True(context.IsGuiCommand);
|
Assert.True(result.Accepted);
|
||||||
Assert.True(context.IsAirAppBrokerCommand);
|
Assert.Equal(Environment.ProcessId, result.Status.HostProcessId);
|
||||||
Assert.True(context.IsDebugMode);
|
Assert.True(result.Status.HostProcessAlive);
|
||||||
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter
|
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter
|
||||||
@@ -9,7 +9,6 @@ public sealed class CommandContextTests
|
|||||||
{
|
{
|
||||||
{ [], "normal" },
|
{ [], "normal" },
|
||||||
{ ["preview-oobe"], "debug-preview" },
|
{ ["preview-oobe"], "debug-preview" },
|
||||||
{ ["apply-update"], "normal" },
|
|
||||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||||
};
|
};
|
||||||
@@ -22,4 +21,12 @@ public sealed class CommandContextTests
|
|||||||
|
|
||||||
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromArgs_DoesNotTreatAirAppBrokerAsLauncherGuiCommand()
|
||||||
|
{
|
||||||
|
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
||||||
|
|
||||||
|
Assert.False(context.IsGuiCommand);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.Deployment;
|
||||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
global using LanMountainDesktop.Launcher.Ipc;
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public sealed class HostActivationPolicyTests
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("launch", "normal", true)]
|
[InlineData("launch", "normal", true)]
|
||||||
[InlineData("launch", "restart", false)]
|
[InlineData("launch", "restart", false)]
|
||||||
[InlineData("apply-update", "normal", false)]
|
|
||||||
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
|
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
|
||||||
string command,
|
string command,
|
||||||
string launchSource,
|
string launchSource,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -47,6 +47,95 @@ public sealed class LauncherArchitectureTests
|
|||||||
Assert.Empty(offenders);
|
Assert.Empty(offenders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LauncherProject_DoesNotOwnUpdateApplyOrRollback()
|
||||||
|
{
|
||||||
|
var launcherFiles = Directory
|
||||||
|
.EnumerateFiles(LauncherProjectRoot, "*.cs", SearchOption.AllDirectories)
|
||||||
|
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var forbiddenTokens = new[]
|
||||||
|
{
|
||||||
|
"LauncherUpdateCommandExecutor",
|
||||||
|
"PlondsUpdateApplier",
|
||||||
|
"UpdateRollbackGateway",
|
||||||
|
"UpdateInstallGateway",
|
||||||
|
"LanMountainDesktop.Services.Update",
|
||||||
|
"apply-update",
|
||||||
|
"rollback --app-root"
|
||||||
|
};
|
||||||
|
|
||||||
|
var offenders = launcherFiles
|
||||||
|
.SelectMany(file => forbiddenTokens
|
||||||
|
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||||
|
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Empty(offenders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LauncherProjectFile_DoesNotSourceLinkHostUpdateImplementation()
|
||||||
|
{
|
||||||
|
var project = File.ReadAllText(Path.Combine(LauncherProjectRoot, "LanMountainDesktop.Launcher.csproj"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(@"..\LanMountainDesktop\Services\Update", project, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("PlondsUpdateApplier", project, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("UpdateRollbackGateway", project, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("UpdateInstallGateway", project, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostUpdateFlow_DoesNotDelegateApplyOrRollbackToLauncher()
|
||||||
|
{
|
||||||
|
var guardedFiles = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateInstallGateway.cs"),
|
||||||
|
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateOrchestrator.cs")
|
||||||
|
};
|
||||||
|
|
||||||
|
var forbiddenTokens = new[]
|
||||||
|
{
|
||||||
|
"LauncherPathResolver",
|
||||||
|
"ResolveLauncherExecutablePath",
|
||||||
|
"apply-update",
|
||||||
|
"rollback --app-root",
|
||||||
|
"Launched Launcher"
|
||||||
|
};
|
||||||
|
|
||||||
|
var offenders = guardedFiles
|
||||||
|
.SelectMany(file => forbiddenTokens
|
||||||
|
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||||
|
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Empty(offenders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostUpdateFlow_OwnsDeltaApplyAndRollbackExecution()
|
||||||
|
{
|
||||||
|
var installGateway = File.ReadAllText(Path.Combine(
|
||||||
|
RepoRoot,
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"Services",
|
||||||
|
"Update",
|
||||||
|
"UpdateInstallGateway.cs"));
|
||||||
|
var orchestrator = File.ReadAllText(Path.Combine(
|
||||||
|
RepoRoot,
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"Services",
|
||||||
|
"Update",
|
||||||
|
"UpdateOrchestrator.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("new PlondsUpdateApplier", installGateway, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DeploymentLockService.ClearLock", installGateway, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("new UpdateRollbackGateway().RollbackLatest", orchestrator, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("LanMountainDesktop.Launcher", orchestrator, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LauncherCompositionRootStaysThin()
|
public void LauncherCompositionRootStaysThin()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
global using LanMountainDesktop.Launcher.AirApp;
|
global using LanMountainDesktop.AirAppRuntime;
|
||||||
global using LanMountainDesktop.Launcher.Deployment;
|
global using LanMountainDesktop.Launcher.Deployment;
|
||||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
global using LanMountainDesktop.Launcher.Ipc;
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|||||||
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");
|
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
|
||||||
|
|
||||||
Assert.Contains("Publish-LauncherPayload", script);
|
Assert.Contains("Publish-LauncherPayload", script);
|
||||||
|
Assert.Contains("Publish-AirAppRuntimePayload", script);
|
||||||
Assert.Contains("\"app-$Version\"", script);
|
Assert.Contains("\"app-$Version\"", script);
|
||||||
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
|
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
|
||||||
Assert.Contains("\"--self-contained\", \"false\"", script);
|
Assert.Contains("\"--self-contained\", \"false\"", script);
|
||||||
Assert.Contains("\"-p:SelfContained=false\"", script);
|
Assert.Contains("\"-p:SelfContained=false\"", script);
|
||||||
|
Assert.Contains("\"-p:PublishAot=false\"", script);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -28,12 +30,13 @@ public sealed class PackagingRuntimePolicyTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost()
|
public void WindowsPayloadGuard_RequiresLauncherRuntimeMainAndAirAppHost()
|
||||||
{
|
{
|
||||||
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
|
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
|
||||||
|
|
||||||
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
|
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
|
||||||
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
|
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
|
||||||
|
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", script);
|
||||||
Assert.Contains("LanMountainDesktop.exe", script);
|
Assert.Contains("LanMountainDesktop.exe", script);
|
||||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
|
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
|
||||||
}
|
}
|
||||||
@@ -44,9 +47,21 @@ public sealed class PackagingRuntimePolicyTests
|
|||||||
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
|
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
|
||||||
|
|
||||||
Assert.Contains("Verify Windows app host payload", workflow);
|
Assert.Contains("Verify Windows app host payload", workflow);
|
||||||
|
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", workflow);
|
||||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
|
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AirAppRuntimeProject_IsFrameworkDependentJit()
|
||||||
|
{
|
||||||
|
var project = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "LanMountainDesktop.AirAppRuntime.csproj");
|
||||||
|
|
||||||
|
Assert.Contains("<PublishAot>false</PublishAot>", project);
|
||||||
|
Assert.Contains("<SelfContained>false</SelfContained>", project);
|
||||||
|
Assert.Contains("<PublishTrimmed>false</PublishTrimmed>", project);
|
||||||
|
Assert.Contains("<PublishReadyToRun>false</PublishReadyToRun>", project);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
|
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
|
||||||
{
|
{
|
||||||
|
|||||||
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 LanMountainDesktop.Launcher.Plugins;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
Directory.CreateDirectory(_tempRoot);
|
Directory.CreateDirectory(_tempRoot);
|
||||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||||
|
|
||||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||||
var service = new PluginInstallerService();
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal("ok", result.Code);
|
Assert.Equal("ok", result.Code);
|
||||||
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstallPackage_AllowsConfiguredPortableDataRootOutsideUserScope()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_tempRoot);
|
||||||
|
var appRoot = Path.Combine(_tempRoot, "PackageRoot");
|
||||||
|
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||||
|
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||||
|
Directory.CreateDirectory(launcherDataRoot);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
DataLocationMode = "Portable",
|
||||||
|
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||||
|
PortableDataPath = portableDataRoot
|
||||||
|
}));
|
||||||
|
|
||||||
|
var packagePath = Path.Combine(_tempRoot, "portable.laapp");
|
||||||
|
CreatePluginPackage(packagePath, "plugin.json", "plugin.portable.sample", "Portable Plugin");
|
||||||
|
|
||||||
|
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||||
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
|
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal("ok", result.Code);
|
||||||
|
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||||
|
Assert.StartsWith(Path.GetFullPath(portableDataRoot), Path.GetFullPath(result.InstalledPackagePath!), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||||
{
|
{
|
||||||
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||||
|
|
||||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||||
var service = new PluginInstallerService();
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
|
||||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
Assert.True(first.Success);
|
Assert.True(first.Success);
|
||||||
Assert.True(second.Success);
|
Assert.True(second.Success);
|
||||||
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
Directory.CreateDirectory(_tempRoot);
|
Directory.CreateDirectory(_tempRoot);
|
||||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||||
|
|
||||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||||
var service = new PluginInstallerService();
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||||
@@ -103,18 +140,24 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CreateUserScopedPluginsDirectory()
|
private string CreateConfiguredPortablePluginsDirectory(out string appRoot)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(
|
appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||||
"LanMountainDesktop",
|
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||||
"Tests",
|
Directory.CreateDirectory(launcherDataRoot);
|
||||||
nameof(PluginInstallerServiceTests),
|
File.WriteAllText(
|
||||||
Guid.NewGuid().ToString("N"),
|
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||||
"Extensions",
|
JsonSerializer.Serialize(new
|
||||||
"Plugins");
|
{
|
||||||
Directory.CreateDirectory(root);
|
DataLocationMode = "Portable",
|
||||||
return root;
|
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||||
|
PortableDataPath = portableDataRoot
|
||||||
|
}));
|
||||||
|
|
||||||
|
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||||
|
Directory.CreateDirectory(pluginsDirectory);
|
||||||
|
return pluginsDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
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.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Plonds;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.Services.Update;
|
using LanMountainDesktop.Services.Update;
|
||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
@@ -103,35 +104,111 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SettingsUpdateManifestProvider_UsesSelectedUpdateSource()
|
public async Task UpdateSettingsService_WhenPlondsSelected_UsesPlondsServiceWithoutCreatingOrchestrator()
|
||||||
{
|
{
|
||||||
var update = new FakeUpdateSettingsService
|
var settings = new FakeSettingsService
|
||||||
{
|
{
|
||||||
State = DefaultUpdateState() with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub }
|
Snapshot =
|
||||||
|
{
|
||||||
|
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
|
||||||
|
}
|
||||||
};
|
};
|
||||||
var plonds = new FakeManifestProvider("plonds");
|
var plonds = new FakePlondsService
|
||||||
var github = new FakeManifestProvider("github");
|
{
|
||||||
var provider = new SettingsUpdateManifestProvider(new FakeSettingsFacade(update), plonds, github);
|
LatestResult = PlondsLatestResult.Available(
|
||||||
|
new Version(1, 0, 0),
|
||||||
|
new Version(9, 9, 9),
|
||||||
|
[new PlondsManifestCandidate(
|
||||||
|
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||||
|
CreatePlondsManifest("9.9.9"))])
|
||||||
|
};
|
||||||
|
var orchestratorCreated = false;
|
||||||
|
var service = new UpdateSettingsService(
|
||||||
|
settings,
|
||||||
|
orchestratorFactory: () =>
|
||||||
|
{
|
||||||
|
orchestratorCreated = true;
|
||||||
|
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS.");
|
||||||
|
},
|
||||||
|
plondsService: plonds);
|
||||||
|
|
||||||
var manifest = await provider.GetLatestAsync(
|
var report = await service.CheckAsync(CancellationToken.None);
|
||||||
UpdateSettingsValues.ChannelStable,
|
|
||||||
"windows-x64",
|
|
||||||
new Version(1, 0, 0),
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal("github", manifest?.DistributionId);
|
Assert.True(report.IsUpdateAvailable);
|
||||||
Assert.Equal(0, plonds.GetLatestCalls);
|
Assert.Equal("9.9.9", report.LatestVersion);
|
||||||
Assert.Equal(1, github.GetLatestCalls);
|
Assert.Equal(1, plonds.FindLatestCalls);
|
||||||
|
Assert.False(orchestratorCreated);
|
||||||
|
}
|
||||||
|
|
||||||
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds };
|
[Fact]
|
||||||
manifest = await provider.GetLatestAsync(
|
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
|
||||||
UpdateSettingsValues.ChannelStable,
|
{
|
||||||
"windows-x64",
|
var settings = new FakeSettingsService
|
||||||
new Version(1, 0, 0),
|
{
|
||||||
CancellationToken.None);
|
Snapshot =
|
||||||
|
{
|
||||||
|
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var plonds = new FakePlondsService
|
||||||
|
{
|
||||||
|
LatestResult = PlondsLatestResult.Available(
|
||||||
|
new Version(1, 0, 0),
|
||||||
|
new Version(9, 9, 9),
|
||||||
|
[new PlondsManifestCandidate(
|
||||||
|
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||||
|
CreatePlondsManifest("9.9.9", requiresCleanInstall: true))])
|
||||||
|
};
|
||||||
|
var orchestratorCreated = false;
|
||||||
|
var service = new UpdateSettingsService(
|
||||||
|
settings,
|
||||||
|
orchestratorFactory: () =>
|
||||||
|
{
|
||||||
|
orchestratorCreated = true;
|
||||||
|
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS check.");
|
||||||
|
},
|
||||||
|
plondsService: plonds);
|
||||||
|
|
||||||
Assert.Equal("plonds", manifest?.DistributionId);
|
var report = await service.CheckAsync(CancellationToken.None);
|
||||||
Assert.Equal(1, plonds.GetLatestCalls);
|
|
||||||
|
Assert.True(report.IsUpdateAvailable);
|
||||||
|
Assert.Equal(UpdatePayloadKind.FullInstaller, report.PayloadKind);
|
||||||
|
Assert.Equal("9.9.9", report.LatestVersion);
|
||||||
|
Assert.False(orchestratorCreated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator()
|
||||||
|
{
|
||||||
|
var settings = new FakeSettingsService
|
||||||
|
{
|
||||||
|
Snapshot =
|
||||||
|
{
|
||||||
|
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with
|
||||||
|
{
|
||||||
|
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||||
|
});
|
||||||
|
var orchestratorCreated = false;
|
||||||
|
var service = new UpdateSettingsService(
|
||||||
|
settings,
|
||||||
|
orchestratorFactory: () =>
|
||||||
|
{
|
||||||
|
orchestratorCreated = true;
|
||||||
|
return orchestrator;
|
||||||
|
},
|
||||||
|
plondsService: new FakePlondsService());
|
||||||
|
|
||||||
|
var _ = service.CurrentPhase;
|
||||||
|
|
||||||
|
Assert.False(orchestratorCreated);
|
||||||
|
|
||||||
|
var report = await service.CheckAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(orchestratorCreated);
|
||||||
|
Assert.True(report.IsUpdateAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -177,6 +254,33 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
LastUpdateCheckUtcMs: null,
|
LastUpdateCheckUtcMs: null,
|
||||||
PendingUpdateSha256: null);
|
PendingUpdateSha256: null);
|
||||||
|
|
||||||
|
private static UpdateOrchestrator CreateTestOrchestrator(SettingsUpdateState state)
|
||||||
|
{
|
||||||
|
return new UpdateOrchestrator(
|
||||||
|
new FakeManifestProvider("github"),
|
||||||
|
new UpdateDownloadEngine(new FakeManifestProvider("github"), new ResumableDownloadService(new HttpClient(new EmptyHandler()))),
|
||||||
|
new UpdateInstallGateway(),
|
||||||
|
new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state })));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlondsClientManifest CreatePlondsManifest(string version, bool requiresCleanInstall = false)
|
||||||
|
{
|
||||||
|
return new PlondsClientManifest(
|
||||||
|
FormatVersion: "2.0",
|
||||||
|
CurrentVersion: version,
|
||||||
|
PreviousVersion: "1.0.0",
|
||||||
|
IsFullUpdate: false,
|
||||||
|
RequiresCleanInstall: requiresCleanInstall,
|
||||||
|
Channel: "stable",
|
||||||
|
Platform: "windows-x64",
|
||||||
|
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),
|
||||||
|
FilesMap: new Dictionary<string, PlondsClientFileEntry>(),
|
||||||
|
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
|
||||||
|
Checksums: new Dictionary<string, string>(),
|
||||||
|
Downloads: null,
|
||||||
|
Sources: []);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeUpdateSettingsService : IUpdateSettingsService
|
private sealed class FakeUpdateSettingsService : IUpdateSettingsService
|
||||||
{
|
{
|
||||||
public SettingsUpdateState State { get; set; } = DefaultUpdateState();
|
public SettingsUpdateState State { get; set; } = DefaultUpdateState();
|
||||||
@@ -263,9 +367,6 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
|
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
|
||||||
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
|
|
||||||
public Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default)
|
|
||||||
=> Task.FromResult<PlondsUpdatePayload?>(null);
|
|
||||||
|
|
||||||
public Task<LanMountainDesktop.Services.UpdateDownloadResult> DownloadAssetAsync(
|
public Task<LanMountainDesktop.Services.UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
@@ -285,6 +386,115 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
=> Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false));
|
=> Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakePlondsService : IPlondsService
|
||||||
|
{
|
||||||
|
public PlondsLatestResult LatestResult { get; set; } = PlondsLatestResult.UpToDate(new Version(1, 0, 0), new Version(1, 0, 0));
|
||||||
|
public PlondsPrepareResult PrepareResult { get; set; } = PlondsPrepareResult.FailedForUi("not prepared");
|
||||||
|
public int FindLatestCalls { get; private set; }
|
||||||
|
public int PrepareLatestCalls { get; private set; }
|
||||||
|
|
||||||
|
public Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
FindLatestCalls++;
|
||||||
|
return Task.FromResult(LatestResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
PrepareLatestCalls++;
|
||||||
|
return Task.FromResult(PrepareResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
PrepareLatestCalls++;
|
||||||
|
return Task.FromResult(PrepareResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSettingsService : ISettingsService
|
||||||
|
{
|
||||||
|
public event EventHandler<SettingsChangedEvent>? Changed;
|
||||||
|
|
||||||
|
public AppSettingsSnapshot Snapshot { get; init; } = new();
|
||||||
|
|
||||||
|
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
|
||||||
|
{
|
||||||
|
if (typeof(T) == typeof(AppSettingsSnapshot))
|
||||||
|
{
|
||||||
|
return (T)(object)Snapshot.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new T();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveSnapshot<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
T snapshot,
|
||||||
|
string? subjectId = null,
|
||||||
|
string? placementId = null,
|
||||||
|
string? sectionId = null,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null)
|
||||||
|
{
|
||||||
|
if (snapshot is AppSettingsSnapshot appSettings)
|
||||||
|
{
|
||||||
|
CopyUpdateSettings(appSettings, Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
Changed?.Invoke(this, new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new()
|
||||||
|
=> new();
|
||||||
|
|
||||||
|
public void SaveSection<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
string subjectId,
|
||||||
|
string sectionId,
|
||||||
|
T section,
|
||||||
|
string? placementId = null,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public T? GetValue<T>(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null)
|
||||||
|
=> default;
|
||||||
|
|
||||||
|
public void SetValue<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
string key,
|
||||||
|
T value,
|
||||||
|
string? subjectId = null,
|
||||||
|
string? placementId = null,
|
||||||
|
string? sectionId = null,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
private static void CopyUpdateSettings(AppSettingsSnapshot source, AppSettingsSnapshot target)
|
||||||
|
{
|
||||||
|
target.IncludePrereleaseUpdates = source.IncludePrereleaseUpdates;
|
||||||
|
target.UpdateChannel = source.UpdateChannel;
|
||||||
|
target.UpdateMode = source.UpdateMode;
|
||||||
|
target.UpdateDownloadSource = source.UpdateDownloadSource;
|
||||||
|
target.UpdateDownloadThreads = source.UpdateDownloadThreads;
|
||||||
|
target.ForceUpdateReinstall = source.ForceUpdateReinstall;
|
||||||
|
target.UseGhProxyMirror = source.UseGhProxyMirror;
|
||||||
|
target.PendingUpdateInstallerPath = source.PendingUpdateInstallerPath;
|
||||||
|
target.PendingUpdateVersion = source.PendingUpdateVersion;
|
||||||
|
target.PendingUpdatePublishedAtUtcMs = source.PendingUpdatePublishedAtUtcMs;
|
||||||
|
target.LastUpdateCheckUtcMs = source.LastUpdateCheckUtcMs;
|
||||||
|
target.PendingUpdateSha256 = source.PendingUpdateSha256;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider
|
private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider
|
||||||
{
|
{
|
||||||
public string ProviderName { get; } = providerName;
|
public string ProviderName { get; } = providerName;
|
||||||
@@ -318,6 +528,14 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
new Dictionary<string, string>());
|
new Dictionary<string, string>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class EmptyHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService
|
private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService
|
||||||
{
|
{
|
||||||
public ISettingsService Settings => throw new NotSupportedException();
|
public ISettingsService Settings => throw new NotSupportedException();
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests
|
|||||||
{
|
{
|
||||||
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
||||||
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
||||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "AirApp", "IAirAppProcessStarter.cs");
|
var starterSource = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "IAirAppProcessStarter.cs");
|
||||||
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
||||||
|
|
||||||
Assert.Contains("DataRoot", optionsSource);
|
Assert.Contains("DataRoot", optionsSource);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
|
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj" />
|
||||||
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||||
|
|||||||
@@ -394,8 +394,6 @@
|
|||||||
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
|
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
|
||||||
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
|
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
|
||||||
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
|
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
|
||||||
"settings.appearance.corner_radius.label": "Global corner radius style",
|
|
||||||
"settings.appearance.corner_radius.description": "Select a fixed corner radius style inspired by Xiaomi HyperOS.",
|
|
||||||
"component.color_scheme.follow_system": "Follow system color scheme",
|
"component.color_scheme.follow_system": "Follow system color scheme",
|
||||||
"component.color_scheme.native": "Use component custom color scheme",
|
"component.color_scheme.native": "Use component custom color scheme",
|
||||||
"component.settings.color_scheme": "Color Scheme",
|
"component.settings.color_scheme": "Color Scheme",
|
||||||
@@ -406,7 +404,7 @@
|
|||||||
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
|
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
|
||||||
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
|
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
|
||||||
"settings.appearance.system_material_desc.auto": "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable.",
|
"settings.appearance.system_material_desc.auto": "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable.",
|
||||||
"settings.appearance.restart_message": "Theme source and system material changes require restarting the app.",
|
"settings.appearance.restart_message": "Window chrome changes require restarting the app.",
|
||||||
"settings.appearance.preview.primary": "Primary",
|
"settings.appearance.preview.primary": "Primary",
|
||||||
"settings.appearance.preview.secondary": "Secondary",
|
"settings.appearance.preview.secondary": "Secondary",
|
||||||
"settings.appearance.preview.tertiary": "Tertiary",
|
"settings.appearance.preview.tertiary": "Tertiary",
|
||||||
@@ -442,6 +440,7 @@
|
|||||||
"settings.material_color.wallpaper_seed.label": "Seed",
|
"settings.material_color.wallpaper_seed.label": "Seed",
|
||||||
"settings.material_color.system_material.label": "System material",
|
"settings.material_color.system_material.label": "System material",
|
||||||
"settings.material_color.system_material.description": "Apply the selected material mode to windows and host surfaces.",
|
"settings.material_color.system_material.description": "Apply the selected material mode to windows and host surfaces.",
|
||||||
|
"settings.material_color.system_material.restart_message": "System material changes require restarting the app.",
|
||||||
"settings.material_color.native_events.label": "Native wallpaper change events",
|
"settings.material_color.native_events.label": "Native wallpaper change events",
|
||||||
"settings.material_color.native_events.description": "Use OS wallpaper notifications first and keep polling as fallback.",
|
"settings.material_color.native_events.description": "Use OS wallpaper notifications first and keep polling as fallback.",
|
||||||
"settings.material_color.native_events.active": "Native wallpaper events active",
|
"settings.material_color.native_events.active": "Native wallpaper events active",
|
||||||
|
|||||||
@@ -394,8 +394,6 @@
|
|||||||
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
||||||
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
||||||
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
||||||
"settings.appearance.corner_radius.label": "全局圆角样式",
|
|
||||||
"settings.appearance.corner_radius.description": "选择固定的全局圆角样式,受 HyperOS 启发。",
|
|
||||||
"component.color_scheme.follow_system": "跟随系统配色",
|
"component.color_scheme.follow_system": "跟随系统配色",
|
||||||
"component.color_scheme.native": "使用组件自定义配色",
|
"component.color_scheme.native": "使用组件自定义配色",
|
||||||
"component.settings.color_scheme": "配色方案",
|
"component.settings.color_scheme": "配色方案",
|
||||||
@@ -406,7 +404,7 @@
|
|||||||
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
|
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
|
||||||
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
|
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
|
||||||
"settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica,在 Windows 10 优先使用 Acrylic,不可用时回退到无材质。",
|
"settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica,在 Windows 10 优先使用 Acrylic,不可用时回退到无材质。",
|
||||||
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
|
"settings.appearance.restart_message": "窗口边框模式更改需要重启应用。",
|
||||||
"settings.appearance.preview.primary": "主色",
|
"settings.appearance.preview.primary": "主色",
|
||||||
"settings.appearance.preview.secondary": "次色",
|
"settings.appearance.preview.secondary": "次色",
|
||||||
"settings.appearance.preview.tertiary": "三次色",
|
"settings.appearance.preview.tertiary": "三次色",
|
||||||
@@ -442,6 +440,7 @@
|
|||||||
"settings.material_color.wallpaper_seed.label": "种子色",
|
"settings.material_color.wallpaper_seed.label": "种子色",
|
||||||
"settings.material_color.system_material.label": "系统材质",
|
"settings.material_color.system_material.label": "系统材质",
|
||||||
"settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。",
|
"settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。",
|
||||||
|
"settings.material_color.system_material.restart_message": "系统材质更改需要重启应用。",
|
||||||
"settings.material_color.native_events.label": "原生壁纸变更事件",
|
"settings.material_color.native_events.label": "原生壁纸变更事件",
|
||||||
"settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。",
|
"settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。",
|
||||||
"settings.material_color.native_events.active": "原生壁纸事件已激活",
|
"settings.material_color.native_events.active": "原生壁纸事件已激活",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
|||||||
public const string WorldClockAppId = "world-clock";
|
public const string WorldClockAppId = "world-clock";
|
||||||
public const string WhiteboardAppId = "whiteboard";
|
public const string WhiteboardAppId = "whiteboard";
|
||||||
|
|
||||||
private const int LauncherIpcRetryCount = 4;
|
private const int RuntimeIpcRetryCount = 4;
|
||||||
|
|
||||||
public void OpenWorldClock(string? sourcePlacementId)
|
public void OpenWorldClock(string? sourcePlacementId)
|
||||||
{
|
{
|
||||||
@@ -82,27 +82,27 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
|||||||
var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
|
var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
|
||||||
if (result.Accepted)
|
if (result.Accepted)
|
||||||
{
|
{
|
||||||
AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
|
AppLogger.Info("AirAppLauncher", $"AirApp Runtime accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
|
AppLogger.Warn("AirAppLauncher", $"AirApp Runtime rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex);
|
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through AirApp Runtime. AppId='{appId}'.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<AirAppOperationResult> SendOpenRequestAsync(AirAppOpenRequest request)
|
private static async Task<AirAppOperationResult> SendOpenRequestAsync(AirAppOpenRequest request)
|
||||||
{
|
{
|
||||||
Exception? lastException = null;
|
Exception? lastException = null;
|
||||||
for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++)
|
for (var attempt = 1; attempt <= RuntimeIpcRetryCount; attempt++)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var client = new LanMountainDesktopIpcClient();
|
using var client = new LanMountainDesktopIpcClient();
|
||||||
await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false);
|
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||||
var proxy = client.CreateProxy<IAirAppLifecycleService>();
|
var proxy = client.CreateProxy<IAirAppLifecycleService>();
|
||||||
return await proxy.OpenAsync(request).ConfigureAwait(false);
|
return await proxy.OpenAsync(request).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@@ -113,9 +113,9 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
|||||||
{
|
{
|
||||||
AppLogger.Warn(
|
AppLogger.Warn(
|
||||||
"AirAppLauncher",
|
"AirAppLauncher",
|
||||||
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.",
|
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppRuntimePipeName}'. Starting AirApp Runtime.",
|
||||||
ex);
|
ex);
|
||||||
TryStartLauncher();
|
TryStartRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(250 * attempt).ConfigureAwait(false);
|
await Task.Delay(250 * attempt).ConfigureAwait(false);
|
||||||
@@ -123,44 +123,52 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.",
|
$"AirApp Runtime IPC is unavailable. Pipe='{IpcConstants.AirAppRuntimePipeName}'.",
|
||||||
lastException);
|
lastException);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId)
|
internal static ProcessStartInfo CreateRuntimeStartInfo(string runtimePath, int requesterProcessId, string? appRoot = null, string? dataRoot = null)
|
||||||
{
|
{
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = AirAppRuntimeProcessStarter.CreateStartInfo(runtimePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(appRoot))
|
||||||
{
|
{
|
||||||
FileName = launcherPath,
|
startInfo.ArgumentList.Add("--app-root");
|
||||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
startInfo.ArgumentList.Add(Path.GetFullPath(appRoot));
|
||||||
UseShellExecute = false
|
}
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("air-app-broker");
|
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add("--data-root");
|
||||||
|
startInfo.ArgumentList.Add(Path.GetFullPath(dataRoot));
|
||||||
|
}
|
||||||
|
|
||||||
startInfo.ArgumentList.Add("--requester-pid");
|
startInfo.ArgumentList.Add("--requester-pid");
|
||||||
startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
return startInfo;
|
return startInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void TryStartLauncher()
|
private static void TryStartRuntime()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
var appRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
|
||||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
var runtimePath = AirAppRuntimePathResolver.ResolveExecutablePath(appRoot, AppContext.BaseDirectory);
|
||||||
|
if (string.IsNullOrWhiteSpace(runtimePath) || !File.Exists(runtimePath))
|
||||||
{
|
{
|
||||||
AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found.");
|
AppLogger.Warn("AirAppLauncher", "Unable to start AirApp Runtime for Air APP request: runtime path was not found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId);
|
var dataRoot = AirAppRuntimeDataRootResolver.ResolveDataRoot(appRoot);
|
||||||
|
var startInfo = CreateRuntimeStartInfo(runtimePath, Environment.ProcessId, appRoot, dataRoot);
|
||||||
_ = Process.Start(startInfo);
|
_ = Process.Start(startInfo);
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"AirAppLauncher",
|
"AirAppLauncher",
|
||||||
$"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
$"Started AirApp Runtime. Path='{runtimePath}'; Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex);
|
AppLogger.Warn("AirAppLauncher", "Failed to start AirApp Runtime for Air APP request.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ public static class AppDataPathProvider
|
|||||||
return Path.Combine(GetDataRoot(), "Wallpapers");
|
return Path.Combine(GetDataRoot(), "Wallpapers");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static void ResetForTests()
|
||||||
|
{
|
||||||
|
_overriddenDataRoot = null;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ResolveDataRootFromArgs(string[] args)
|
private static string? ResolveDataRootFromArgs(string[] args)
|
||||||
{
|
{
|
||||||
const string prefix = "--data-root=";
|
const string prefix = "--data-root=";
|
||||||
|
|||||||
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,
|
GitHubReleaseInfo? Release,
|
||||||
GitHubReleaseAsset? PreferredAsset,
|
GitHubReleaseAsset? PreferredAsset,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
bool ForceMode = false,
|
bool ForceMode = false);
|
||||||
PlondsUpdatePayload? PlondsPayload = null);
|
|
||||||
|
|
||||||
public sealed record PlondsUpdatePayload(
|
|
||||||
string DistributionId,
|
|
||||||
string ChannelId,
|
|
||||||
string SubChannel,
|
|
||||||
string? FileMapJson,
|
|
||||||
string? FileMapSignature,
|
|
||||||
string? FileMapJsonUrl,
|
|
||||||
string? FileMapSignatureUrl,
|
|
||||||
string? UpdateArchiveUrl = null,
|
|
||||||
string? UpdateArchiveSha256 = null,
|
|
||||||
long? UpdateArchiveSizeBytes = null);
|
|
||||||
|
|
||||||
public sealed record UpdateDownloadResult(
|
public sealed record UpdateDownloadResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
@@ -162,10 +149,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
var preferredAsset = isUpdateAvailable
|
var preferredAsset = isUpdateAvailable
|
||||||
? SelectPreferredInstallerAsset(release.Assets)
|
? SelectPreferredInstallerAsset(release.Assets)
|
||||||
: null;
|
: null;
|
||||||
var plondsPayload = isUpdateAvailable
|
|
||||||
? TryResolvePlondsPayload(release)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
IsUpdateAvailable: isUpdateAvailable,
|
IsUpdateAvailable: isUpdateAvailable,
|
||||||
@@ -173,8 +156,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
LatestVersionText: latestVersionText,
|
LatestVersionText: latestVersionText,
|
||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null,
|
ErrorMessage: null);
|
||||||
PlondsPayload: plondsPayload);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -239,8 +221,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
: release.TagName;
|
: release.TagName;
|
||||||
|
|
||||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||||
var plondsPayload = TryResolvePlondsPayload(release);
|
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
IsUpdateAvailable: true,
|
IsUpdateAvailable: true,
|
||||||
@@ -249,8 +229,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null,
|
ErrorMessage: null,
|
||||||
ForceMode: true,
|
ForceMode: true);
|
||||||
PlondsPayload: plondsPayload);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -703,46 +682,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
|
||||||
{
|
|
||||||
if (release.Assets is null || release.Assets.Count == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var platformSuffix = GetPlatformAssetSuffix();
|
|
||||||
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
|
||||||
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
|
||||||
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
|
||||||
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
|
||||||
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
|
||||||
var channelId = release.IsPrerelease
|
|
||||||
? UpdateSettingsValues.ChannelPreview
|
|
||||||
: UpdateSettingsValues.ChannelStable;
|
|
||||||
|
|
||||||
return new PlondsUpdatePayload(
|
|
||||||
DistributionId: distributionId,
|
|
||||||
ChannelId: channelId,
|
|
||||||
SubChannel: platformSuffix,
|
|
||||||
FileMapJson: null,
|
|
||||||
FileMapSignature: null,
|
|
||||||
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
|
||||||
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
|
||||||
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
|
||||||
UpdateArchiveSha256: archiveAsset.Sha256,
|
|
||||||
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
|
||||||
{
|
|
||||||
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetPlatformAssetSuffix()
|
private static string GetPlatformAssetSuffix()
|
||||||
{
|
{
|
||||||
var os = OperatingSystem.IsWindows()
|
var os = OperatingSystem.IsWindows()
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
@@ -11,8 +9,6 @@ namespace LanMountainDesktop.Services;
|
|||||||
|
|
||||||
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||||
{
|
{
|
||||||
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
|
|
||||||
|
|
||||||
public bool TryExit(HostApplicationLifecycleRequest? request = null)
|
public bool TryExit(HostApplicationLifecycleRequest? request = null)
|
||||||
{
|
{
|
||||||
App? app = null;
|
App? app = null;
|
||||||
@@ -53,11 +49,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (HasPendingPluginUpgrades())
|
|
||||||
{
|
|
||||||
return TryRestartWithUpgradeHelper(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return TryRestartDirectly(request);
|
return TryRestartDirectly(request);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -68,61 +59,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasPendingPluginUpgrades()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pluginsDirectory = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
||||||
"LanMountainDesktop",
|
|
||||||
"Extensions",
|
|
||||||
"Plugins");
|
|
||||||
var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json");
|
|
||||||
return File.Exists(pendingUpgradesPath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request)
|
|
||||||
{
|
|
||||||
AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart.");
|
|
||||||
|
|
||||||
var helperPath = ResolveUpgradeHelperPath();
|
|
||||||
if (!File.Exists(helperPath))
|
|
||||||
{
|
|
||||||
AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart.");
|
|
||||||
return TryRestartDirectly(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
var pluginsDirectory = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
||||||
"LanMountainDesktop",
|
|
||||||
"Extensions",
|
|
||||||
"Plugins");
|
|
||||||
|
|
||||||
var app = Application.Current as App;
|
|
||||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
|
||||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
|
||||||
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
|
|
||||||
var launchArgs = startInfo?.Arguments ?? "";
|
|
||||||
|
|
||||||
var helperStartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = helperPath,
|
|
||||||
Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"",
|
|
||||||
UseShellExecute = true,
|
|
||||||
WorkingDirectory = AppContext.BaseDirectory
|
|
||||||
};
|
|
||||||
|
|
||||||
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
|
|
||||||
|
|
||||||
Process.Start(helperStartInfo);
|
|
||||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||||
{
|
{
|
||||||
var app = Application.Current as App;
|
var app = Application.Current as App;
|
||||||
@@ -149,8 +85,4 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
|
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveUpgradeHelperPath()
|
|
||||||
{
|
|
||||||
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 RenderModeReason = "RenderMode";
|
||||||
public const string PluginCatalogReason = "PluginCatalog";
|
public const string PluginCatalogReason = "PluginCatalog";
|
||||||
public const string SettingsWindowReason = "SettingsWindow";
|
public const string SettingsWindowReason = "SettingsWindow";
|
||||||
|
public const string SystemMaterialReason = "SystemMaterial";
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
private static readonly object Gate = new();
|
||||||
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -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