mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
Compare commits
15 Commits
76d13ac024
...
codex/ipc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00747f33b2 | ||
|
|
2c48b7b846 | ||
|
|
9224c9a33a | ||
|
|
703ed7b48a | ||
|
|
5af7ac8b56 | ||
|
|
4cb52e56c7 | ||
|
|
03e32ee6cb | ||
|
|
c2cc62b58b | ||
|
|
9c529f2992 | ||
|
|
1e9ead8bee | ||
|
|
5f7b3a1e7d | ||
|
|
b12dd68ba7 | ||
|
|
1b22e9df4a | ||
|
|
ce5acf5bd7 | ||
|
|
b933f3badf |
21
.github/workflows/build.yml
vendored
21
.github/workflows/build.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -10,6 +10,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.slnx
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
|
DOTNET_gcServer: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -31,6 +32,7 @@ jobs:
|
|||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
@@ -63,12 +65,22 @@ jobs:
|
|||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libfontconfig1 libfreetype6 \
|
libfontconfig1 libfreetype6 \
|
||||||
libx11-6 libxrandr2 libxinerama1 \
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
libxi6 libxcursor1 libxext6
|
libxi6 libxcursor1 libxext6 \
|
||||||
|
libxrender1 libxkbcommon-x11-0 \
|
||||||
|
clang zlib1g-dev
|
||||||
|
|
||||||
|
# Ubuntu 24.04+ moved several packages to t64 names.
|
||||||
|
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||||
|
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||||
|
|
||||||
|
# Prefer modern WebKit package, fallback for older images.
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
@@ -95,10 +107,14 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: brew install portaudio
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
@@ -129,6 +145,7 @@ jobs:
|
|||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Pack SDK and template packages
|
- name: Pack SDK and template packages
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
6
.github/workflows/code-quality.yml
vendored
6
.github/workflows/code-quality.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Quality Check
|
name: Quality Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.slnx
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
|
DOTNET_gcServer: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
@@ -24,12 +25,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
|
|||||||
166
.github/workflows/ddss-publish.yml
vendored
Normal file
166
.github/workflows/ddss-publish.yml
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
name: DDSS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- PLONDS
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
|
if [[ "$RAW_TAG" == v* ]]; then
|
||||||
|
TAG="$RAW_TAG"
|
||||||
|
else
|
||||||
|
TAG="v$RAW_TAG"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
|
||||||
|
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: preview
|
||||||
|
|
||||||
|
- name: Prepare signing key
|
||||||
|
env:
|
||||||
|
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||||
|
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
||||||
|
PDC_SIGNING_KEY: ${{ secrets.PDC_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 KEY="${PDC_SIGNING_KEY:-}"; fi
|
||||||
|
if [[ -z "$KEY" ]]; then
|
||||||
|
echo "No signing key is configured."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$KEY" > update-private-key.pem
|
||||||
|
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build PLONDS tool
|
||||||
|
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||||
|
|
||||||
|
- name: Download release assets
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p release-assets
|
||||||
|
gh release download "$RELEASE_TAG" -D release-assets
|
||||||
|
find release-assets -maxdepth 1 -type f | sort
|
||||||
|
|
||||||
|
- name: Upload release assets to Rainyun S3
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||||
|
AWS_REGION: ${{ vars.S3_REGION }}
|
||||||
|
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||||
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
aws --version
|
||||||
|
for file in release-assets/*; do
|
||||||
|
[[ -f "$file" ]] || continue
|
||||||
|
name="$(basename "$file")"
|
||||||
|
if [[ "$name" == "ddss.json" || "$name" == "ddss.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: Build DDSS manifest
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p ddss-output
|
||||||
|
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||||
|
build-ddss \
|
||||||
|
--release-tag "$RELEASE_TAG" \
|
||||||
|
--assets-dir release-assets \
|
||||||
|
--output-dir ddss-output \
|
||||||
|
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
||||||
|
--repository "${{ github.repository }}" \
|
||||||
|
--s3-base-url "$S3_BASE_URL"
|
||||||
|
|
||||||
|
- name: Upload DDSS manifest to release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
|
||||||
|
|
||||||
|
- name: Upload DDSS manifest 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
|
||||||
|
for file in ddss-output/ddss.json ddss-output/ddss.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
|
||||||
235
.github/workflows/plonds-build.yml
vendored
Normal file
235
.github/workflows/plonds-build.yml
vendored
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
name: PLONDS
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
- prereleased
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
baseline_tag:
|
||||||
|
description: 'Optional baseline tag'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
channel:
|
||||||
|
description: 'Update channel'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: stable
|
||||||
|
options:
|
||||||
|
- stable
|
||||||
|
- preview
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Resolve release context
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||||
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
|
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
||||||
|
CHANNEL="preview"
|
||||||
|
else
|
||||||
|
CHANNEL="stable"
|
||||||
|
fi
|
||||||
|
BASELINE_TAG=""
|
||||||
|
else
|
||||||
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
|
if [[ "${RAW_TAG}" == v* ]]; then
|
||||||
|
TAG="${RAW_TAG}"
|
||||||
|
else
|
||||||
|
TAG="v${RAW_TAG}"
|
||||||
|
fi
|
||||||
|
CHANNEL="${{ github.event.inputs.channel }}"
|
||||||
|
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
||||||
|
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||||
|
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: preview
|
||||||
|
|
||||||
|
- name: Prepare signing key
|
||||||
|
env:
|
||||||
|
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||||
|
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
||||||
|
PDC_SIGNING_KEY: ${{ secrets.PDC_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 KEY="${PDC_SIGNING_KEY:-}"; fi
|
||||||
|
if [[ -z "$KEY" ]]; then
|
||||||
|
echo "No signing key is configured."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$KEY" > update-private-key.pem
|
||||||
|
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build PLONDS tool
|
||||||
|
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||||
|
|
||||||
|
- name: Resolve baseline plan
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repo = '${{ github.repository }}'
|
||||||
|
$tag = $env:RELEASE_TAG
|
||||||
|
$baselineInput = $env:BASELINE_TAG_INPUT
|
||||||
|
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
|
||||||
|
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
|
||||||
|
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
|
||||||
|
|
||||||
|
$entries = foreach ($platform in $platforms) {
|
||||||
|
$assetName = "files-$platform.zip"
|
||||||
|
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
|
||||||
|
if (-not $currentAsset) {
|
||||||
|
throw "Current release $tag does not contain required asset $assetName"
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineRelease = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
|
||||||
|
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
|
||||||
|
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
|
||||||
|
if (-not $baselineRelease) {
|
||||||
|
throw "Specified baseline tag not found: $normalizedBaseline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$baselineRelease = $allReleases |
|
||||||
|
Where-Object {
|
||||||
|
$_.tag_name -ne $tag -and
|
||||||
|
-not $_.draft -and
|
||||||
|
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
|
||||||
|
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
|
||||||
|
} |
|
||||||
|
Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
platform = $platform
|
||||||
|
assetName = $assetName
|
||||||
|
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
|
||||||
|
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
|
||||||
|
isFullPayload = -not $baselineRelease
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = [pscustomobject]@{
|
||||||
|
tag = $tag
|
||||||
|
version = $env:RELEASE_VERSION
|
||||||
|
channel = $env:RELEASE_CHANNEL
|
||||||
|
platforms = $entries
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
|
||||||
|
Get-Content plonds-plan.json
|
||||||
|
|
||||||
|
- name: Download payload zips
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repo = '${{ github.repository }}'
|
||||||
|
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
||||||
|
|
||||||
|
foreach ($entry in $plan.platforms) {
|
||||||
|
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
|
||||||
|
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
|
||||||
|
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
|
||||||
|
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
|
||||||
|
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
|
||||||
|
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Build delta assets
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
||||||
|
foreach ($entry in $plan.platforms) {
|
||||||
|
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
|
||||||
|
$args = @(
|
||||||
|
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
|
||||||
|
'build-delta',
|
||||||
|
'--platform', $entry.platform,
|
||||||
|
'--current-version', $plan.version,
|
||||||
|
'--current-tag', $plan.tag,
|
||||||
|
'--current-zip', $currentZip,
|
||||||
|
'--output-dir', 'plonds-output',
|
||||||
|
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
|
||||||
|
'--channel', $plan.channel
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([bool]$entry.isFullPayload) {
|
||||||
|
$args += @('--is-full-payload', 'true')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
|
||||||
|
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
|
||||||
|
}
|
||||||
|
|
||||||
|
dotnet @args
|
||||||
|
}
|
||||||
|
|
||||||
|
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
|
||||||
|
build-index `
|
||||||
|
--release-tag $plan.tag `
|
||||||
|
--version $plan.version `
|
||||||
|
--channel $plan.channel `
|
||||||
|
--platform-summaries-dir plonds-output/platform-summaries `
|
||||||
|
--output-dir plonds-output `
|
||||||
|
--private-key $env:UPDATE_PRIVATE_KEY_PATH
|
||||||
|
|
||||||
|
- name: Upload PLONDS assets to release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
|
||||||
|
|
||||||
|
- name: Persist run metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p plonds-run-metadata
|
||||||
|
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
||||||
|
|
||||||
|
- name: Upload run metadata artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: plonds-run-metadata
|
||||||
|
path: plonds-run-metadata/tag.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
542
.github/workflows/release.yml
vendored
542
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -19,6 +19,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.slnx
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
|
DOTNET_gcServer: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
@@ -29,14 +30,22 @@ jobs:
|
|||||||
informational_version: ${{ steps.version.outputs.informational_version }}
|
informational_version: ${{ steps.version.outputs.informational_version }}
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
|
release_channel: ${{ steps.version.outputs.release_channel }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository metadata
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Get release info
|
- name: Get release info
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
CHECKOUT_REF="${GITHUB_REF}"
|
CHECKOUT_REF="${GITHUB_REF}"
|
||||||
|
IS_PRERELEASE="false"
|
||||||
else
|
else
|
||||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
||||||
@@ -46,19 +55,40 @@ jobs:
|
|||||||
else
|
else
|
||||||
TAG="v${RAW_TAG}"
|
TAG="v${RAW_TAG}"
|
||||||
fi
|
fi
|
||||||
CHECKOUT_REF="${GITHUB_SHA}"
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||||
|
CHECKOUT_REF="refs/tags/${TAG}"
|
||||||
|
else
|
||||||
|
CHECKOUT_REF="${GITHUB_SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
|
||||||
|
IS_PRERELEASE="true"
|
||||||
|
else
|
||||||
|
IS_PRERELEASE="false"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
||||||
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
||||||
VERSION_PARTS+=("0")
|
VERSION_PARTS+=("0")
|
||||||
done
|
done
|
||||||
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
||||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
|
RELEASE_CHANNEL="preview"
|
||||||
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
|
else
|
||||||
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
RELEASE_CHANNEL="stable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -67,17 +97,12 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# 完整版(自包含 .NET 运行时)
|
|
||||||
- arch: x64
|
- arch: x64
|
||||||
self_contained: true
|
self_contained: true
|
||||||
suffix: ''
|
suffix: ''
|
||||||
- arch: x86
|
- arch: x86
|
||||||
self_contained: true
|
self_contained: true
|
||||||
suffix: ''
|
suffix: ''
|
||||||
# 轻盈版(框架依赖,仅 x64)
|
|
||||||
- arch: x64
|
|
||||||
self_contained: false
|
|
||||||
suffix: '-lite'
|
|
||||||
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -92,6 +117,7 @@ jobs:
|
|||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
@@ -104,7 +130,35 @@ 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
|
- name: Publish Launcher (AOT)
|
||||||
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
|
||||||
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./$launcherPublishDir `
|
||||||
|
--self-contained `
|
||||||
|
-r win-$arch `
|
||||||
|
-p:PublishAot=true `
|
||||||
|
-p:PublishSingleFile=true `
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||||
|
-p:EnableCompressionInSingleFile=true `
|
||||||
|
-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 }}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Launcher AOT publish failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
||||||
@@ -139,78 +193,68 @@ 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 }}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Published to: $publishDir"
|
|
||||||
Write-Host "Self-contained: $selfContained"
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Install Inno Setup
|
- name: Restructure for Launcher
|
||||||
run: choco install innosetup -y --no-progress
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||||
|
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||||
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
$appDir = "app-$version"
|
||||||
|
$newStructure = "publish-launcher/windows-$arch"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||||
|
$appPath = Join-Path $newStructure $appDir
|
||||||
|
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||||
|
|
||||||
|
if (Test-Path $launcherPublishDir) {
|
||||||
|
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||||
|
|
||||||
|
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Install Inno Setup and 7z
|
||||||
|
run: |
|
||||||
|
choco install innosetup -y --no-progress
|
||||||
|
choco install 7zip -y --no-progress
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Build Installer
|
- name: Build Installer
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
|
||||||
$suffix = "${{ matrix.suffix }}"
|
$suffix = "${{ matrix.suffix }}"
|
||||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||||
$outputDir = "build-installer"
|
$outputDir = "build-installer"
|
||||||
|
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
||||||
|
|
||||||
# Verify source directory exists
|
|
||||||
if (-not (Test-Path -Path $publishDir)) {
|
|
||||||
Write-Error "Publish directory not found: $publishDir"
|
|
||||||
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create output directory
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||||
|
|
||||||
# Verify installer script exists
|
|
||||||
if (-not (Test-Path -Path $installerScript)) {
|
|
||||||
Write-Error "Installer script not found: $installerScript"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Find Inno Setup compiler (choco may install a shim in PATH)
|
|
||||||
$isccPath = $null
|
|
||||||
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
|
||||||
if ($isccCommand) {
|
|
||||||
$isccPath = $isccCommand.Source
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidatePaths = @(
|
$candidatePaths = @(
|
||||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
|
||||||
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\bin\ISCC.exe",
|
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
||||||
)
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
|
||||||
|
$isccPath = $candidatePaths | Select-Object -First 1
|
||||||
if (-not $isccPath) {
|
if (-not $isccPath) {
|
||||||
foreach ($candidate in $candidatePaths) {
|
|
||||||
if ($candidate -and (Test-Path -Path $candidate)) {
|
|
||||||
$isccPath = $candidate
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $isccPath) {
|
|
||||||
Write-Host "ISCC.exe was not found in PATH or known locations."
|
|
||||||
Write-Host "Checked locations:"
|
|
||||||
$candidatePaths | ForEach-Object { Write-Host " - $_" }
|
|
||||||
Write-Host "Chocolatey bin listing (if exists):"
|
|
||||||
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
|
|
||||||
Write-Error "Inno Setup compiler not found."
|
Write-Error "Inno Setup compiler not found."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Found Inno Setup at: $isccPath"
|
if (-not (Test-Path $installerScript)) {
|
||||||
|
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
|
||||||
# Build installer with iscc.exe
|
exit 1
|
||||||
Write-Host "Building installer for Windows $arch with version $version..."
|
}
|
||||||
|
|
||||||
$publishDir = (Resolve-Path $publishDir).Path
|
$publishDir = (Resolve-Path $publishDir).Path
|
||||||
$outputDir = (Resolve-Path $outputDir).Path
|
$outputDir = (Resolve-Path $outputDir).Path
|
||||||
@@ -226,31 +270,64 @@ jobs:
|
|||||||
$installerScript
|
$installerScript
|
||||||
)
|
)
|
||||||
|
|
||||||
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
|
|
||||||
|
|
||||||
# Execute the compiler
|
|
||||||
& $isccPath @compileArgs
|
& $isccPath @compileArgs
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if build was successful
|
|
||||||
$installerFile = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
$installerFile = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
if (-not $installerFile) {
|
if (-not $installerFile) {
|
||||||
Write-Error "Failed to create installer"
|
Write-Error "Failed to create installer"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "✅ Successfully created: $($installerFile.Name)"
|
|
||||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload Installer
|
- name: Package Payload Zip
|
||||||
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||||
|
if (-not (Test-Path $payloadRoot)) {
|
||||||
|
Write-Error "Payload root not found: $payloadRoot"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$stageDir = Join-Path $PWD "payload-stage/windows-$arch"
|
||||||
|
$releaseDir = Join-Path $PWD "release-assets"
|
||||||
|
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
|
||||||
|
|
||||||
|
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||||
|
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||||
|
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
|
||||||
|
$destinationDir = Split-Path -Path $destination -Parent
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
|
||||||
|
if (Test-Path $payloadZip) {
|
||||||
|
Remove-Item $payloadZip -Force
|
||||||
|
}
|
||||||
|
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Upload Release Assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
|
name: release-windows-${{ matrix.arch }}
|
||||||
path: build-installer/*.exe
|
path: |
|
||||||
|
release-assets/files-windows-${{ matrix.arch }}.zip
|
||||||
|
build-installer/*.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
@@ -274,12 +351,18 @@ jobs:
|
|||||||
libfontconfig1 libfreetype6 \
|
libfontconfig1 libfreetype6 \
|
||||||
libx11-6 libxrandr2 libxinerama1 \
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
libxi6 libxcursor1 libxext6 \
|
libxi6 libxcursor1 libxext6 \
|
||||||
libxrender1 libxkbcommon-x11-0
|
libxrender1 libxkbcommon-x11-0 \
|
||||||
|
clang zlib1g-dev zip rsync
|
||||||
|
|
||||||
|
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||||
|
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
@@ -292,11 +375,29 @@ 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
|
- name: Publish Launcher (AOT)
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/launcher-linux-x64 \
|
||||||
|
--self-contained \
|
||||||
|
-r linux-x64 \
|
||||||
|
-p:PublishAot=true \
|
||||||
|
-p:PublishSingleFile=true \
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
|
-p:EnableCompressionInSingleFile=true \
|
||||||
|
-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: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/linux-x64 \
|
-o ./publish/linux-x64-app \
|
||||||
--self-contained \
|
--self-contained \
|
||||||
-r linux-x64 \
|
-r linux-x64 \
|
||||||
-p:PublishSingleFile=false \
|
-p:PublishSingleFile=false \
|
||||||
@@ -310,6 +411,24 @@ 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: Restructure for Launcher
|
||||||
|
run: |
|
||||||
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
|
publishDir="publish/linux-x64"
|
||||||
|
appDir="app-$version"
|
||||||
|
launcherDir="publish/launcher-linux-x64"
|
||||||
|
|
||||||
|
mkdir -p "$publishDir"
|
||||||
|
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||||
|
|
||||||
|
if [ -d "$launcherDir" ]; then
|
||||||
|
cp -r "$launcherDir"/* "$publishDir/"
|
||||||
|
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "$publishDir/$appDir/.current"
|
||||||
|
rm -rf "$launcherDir"
|
||||||
|
|
||||||
- name: Package as DEB
|
- name: Package as DEB
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
@@ -320,40 +439,16 @@ jobs:
|
|||||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
||||||
|
|
||||||
# Verify source directory exists
|
|
||||||
if [ ! -d "$source" ]; then
|
|
||||||
echo "Error: Source directory not found: $source"
|
|
||||||
ls -la publish/ || echo "publish directory not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create DEB package structure
|
|
||||||
mkdir -p "build-deb/DEBIAN"
|
mkdir -p "build-deb/DEBIAN"
|
||||||
mkdir -p "build-deb/usr/local/bin"
|
mkdir -p "build-deb/usr/local/bin"
|
||||||
mkdir -p "build-deb/usr/share/applications"
|
mkdir -p "build-deb/usr/share/applications"
|
||||||
mkdir -p "build-deb/usr/share/pixmaps"
|
mkdir -p "build-deb/usr/share/pixmaps"
|
||||||
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
|
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
|
||||||
|
|
||||||
# Copy application files
|
|
||||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
cp -r "$source"/* "build-deb/usr/local/bin/"
|
||||||
|
|
||||||
# Verify copy was successful
|
|
||||||
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
|
|
||||||
echo "DEB package contains $item_count files"
|
|
||||||
|
|
||||||
if [ "$item_count" -eq 0 ]; then
|
|
||||||
echo "Error: DEB package is empty after copy"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
|
|
||||||
echo "Error: Linux desktop resources are missing"
|
|
||||||
ls -la "LanMountainDesktop/packaging/linux" || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
sed \
|
sed \
|
||||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
|
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
||||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||||
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||||
|
|
||||||
@@ -371,44 +466,64 @@ jobs:
|
|||||||
printf '%s\n' 'fi'
|
printf '%s\n' 'fi'
|
||||||
} > "build-deb/DEBIAN/postinst"
|
} > "build-deb/DEBIAN/postinst"
|
||||||
|
|
||||||
# Create control file (NOTE: No leading spaces in control file)
|
|
||||||
{
|
{
|
||||||
printf '%s\n' "Package: $package_name"
|
printf '%s\n' "Package: $package_name"
|
||||||
printf '%s\n' "Version: $package_version"
|
printf '%s\n' "Version: $package_version"
|
||||||
printf '%s\n' "Architecture: $arch"
|
printf '%s\n' "Architecture: $arch"
|
||||||
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
|
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
|
||||||
printf '%s\n' "Description: LanMountain Desktop Application"
|
printf '%s\n' 'Description: LanMountain Desktop Application'
|
||||||
printf '%s\n' " A desktop application for LanMountain."
|
printf '%s\n' ' A desktop application for LanMountain.'
|
||||||
} > "build-deb/DEBIAN/control"
|
} > "build-deb/DEBIAN/control"
|
||||||
|
|
||||||
# Set proper permissions
|
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
|
||||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
|
|
||||||
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||||
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
|
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
|
||||||
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||||
chmod 755 "build-deb/DEBIAN/postinst"
|
chmod 755 "build-deb/DEBIAN/postinst"
|
||||||
|
|
||||||
# Create DEB file
|
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
|
||||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
|
||||||
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
|
- name: Package Payload Zip
|
||||||
ls -lh "${package_name}_${package_version}_${arch}.deb"
|
run: |
|
||||||
else
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
echo "Error: Failed to build DEB package"
|
payload_root="publish/linux-x64/app-$version"
|
||||||
|
release_dir="$PWD/release-assets"
|
||||||
|
stage_dir="$PWD/payload-stage/linux-x64"
|
||||||
|
|
||||||
|
if [ ! -d "$payload_root" ]; then
|
||||||
|
echo "Payload root not found: $payload_root"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload
|
rm -rf "$stage_dir"
|
||||||
|
mkdir -p "$stage_dir" "$release_dir"
|
||||||
|
rsync -a \
|
||||||
|
--exclude '.current' \
|
||||||
|
--exclude '.partial' \
|
||||||
|
--exclude '.destroy' \
|
||||||
|
"$payload_root/" "$stage_dir/"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$stage_dir"
|
||||||
|
zip -qr "$release_dir/files-linux-x64.zip" .
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Upload Release Assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-linux
|
name: release-linux-x64
|
||||||
path: "*.deb"
|
path: |
|
||||||
|
release-assets/files-linux-x64.zip
|
||||||
|
*.deb
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
continue-on-error: true
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [x64, arm64]
|
arch: [x64, arm64]
|
||||||
name: Build_macOS_${{ matrix.arch }}
|
name: Build_macOS_${{ matrix.arch }}
|
||||||
@@ -421,10 +536,14 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: brew install portaudio
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
dotnet-quality: 'preview'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
@@ -437,11 +556,29 @@ 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
|
- name: Publish Launcher (AOT)
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||||
|
--self-contained \
|
||||||
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:PublishAot=true \
|
||||||
|
-p:PublishSingleFile=true \
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
|
-p:EnableCompressionInSingleFile=true \
|
||||||
|
-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: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/macos-${{ matrix.arch }} \
|
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||||
--self-contained \
|
--self-contained \
|
||||||
-r osx-${{ matrix.arch }} \
|
-r osx-${{ matrix.arch }} \
|
||||||
-p:PublishSingleFile=false \
|
-p:PublishSingleFile=false \
|
||||||
@@ -455,45 +592,50 @@ 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: Package as DMG
|
- name: Package Payload Zip
|
||||||
|
run: |
|
||||||
|
release_dir="$PWD/release-assets"
|
||||||
|
stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}"
|
||||||
|
payload_root="publish/macos-${{ matrix.arch }}-app"
|
||||||
|
|
||||||
|
rm -rf "$stage_dir"
|
||||||
|
mkdir -p "$stage_dir" "$release_dir"
|
||||||
|
rsync -a "$payload_root/" "$stage_dir/"
|
||||||
|
(
|
||||||
|
cd "$stage_dir"
|
||||||
|
zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" .
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Restructure and Package as DMG
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
arch="${{ matrix.arch }}"
|
arch="${{ matrix.arch }}"
|
||||||
source="publish/macos-$arch"
|
|
||||||
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"
|
||||||
|
appSourceDir="publish/macos-$arch-app"
|
||||||
|
|
||||||
# Verify source directory exists
|
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||||
if [ ! -d "$source" ]; then
|
appDir="app-$version"
|
||||||
echo "Error: Source directory not found: $source"
|
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
||||||
ls -la publish/ || echo "publish directory not found"
|
|
||||||
exit 1
|
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||||
|
if [ -d "$launcherDir" ]; then
|
||||||
|
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
||||||
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create app bundle structure
|
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
|
||||||
mkdir -p "${app_name}.app/Contents/Resources"
|
mkdir -p "${app_name}.app/Contents/Resources"
|
||||||
|
|
||||||
# Copy application files
|
|
||||||
cp -r "$source"/* "${app_name}.app/Contents/MacOS/"
|
|
||||||
|
|
||||||
# Verify copy was successful
|
|
||||||
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
|
|
||||||
echo "App bundle contains $item_count files"
|
|
||||||
|
|
||||||
if [ "$item_count" -eq 0 ]; then
|
|
||||||
echo "Error: App bundle is empty after copy"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create Info.plist
|
|
||||||
{
|
{
|
||||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||||
printf '%s\n' '<plist version="1.0">'
|
printf '%s\n' '<plist version="1.0">'
|
||||||
printf '%s\n' '<dict>'
|
printf '%s\n' '<dict>'
|
||||||
printf '%s\n' ' <key>CFBundleExecutable</key>'
|
printf '%s\n' ' <key>CFBundleExecutable</key>'
|
||||||
printf '%s\n' ' <string>LanMountainDesktop</string>'
|
printf '%s\n' ' <string>LanMountainDesktop.Launcher</string>'
|
||||||
printf '%s\n' ' <key>CFBundleName</key>'
|
printf '%s\n' ' <key>CFBundleName</key>'
|
||||||
printf '%s\n' ' <string>LanMountain Desktop</string>'
|
printf '%s\n' ' <string>LanMountain Desktop</string>'
|
||||||
printf '%s\n' ' <key>CFBundleVersion</key>'
|
printf '%s\n' ' <key>CFBundleVersion</key>'
|
||||||
@@ -508,98 +650,98 @@ jobs:
|
|||||||
printf '%s\n' '</plist>'
|
printf '%s\n' '</plist>'
|
||||||
} > "${app_name}.app/Contents/Info.plist"
|
} > "${app_name}.app/Contents/Info.plist"
|
||||||
|
|
||||||
# Create DMG
|
|
||||||
mkdir -p dmg-temp
|
mkdir -p dmg-temp
|
||||||
cp -r "${app_name}.app" dmg-temp/
|
cp -r "${app_name}.app" dmg-temp/
|
||||||
|
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
|
||||||
|
|
||||||
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
|
- name: Upload Release Assets
|
||||||
echo "Successfully created: ${package_name}.dmg"
|
if: always()
|
||||||
ls -lh "${package_name}.dmg"
|
|
||||||
else
|
|
||||||
echo "Error: Failed to create DMG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
rm -rf dmg-temp "${app_name}.app"
|
|
||||||
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-macos-${{ matrix.arch }}
|
name: release-macos-${{ matrix.arch }}
|
||||||
path: "*.dmg"
|
path: |
|
||||||
if-no-files-found: error
|
release-assets/files-macos-${{ matrix.arch }}.zip
|
||||||
|
*.dmg
|
||||||
|
if-no-files-found: ignore
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
github-release:
|
github-release:
|
||||||
needs: [ prepare, build-windows, build-linux, build-macos ]
|
needs: [prepare, build-windows, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: release-files
|
||||||
pattern: release-*
|
pattern: release-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: List artifacts structure
|
- name: Normalize release files
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Artifact directory structure:"
|
mkdir -p release-bundle
|
||||||
find artifacts -type f -o -type d | sort
|
|
||||||
echo ""
|
|
||||||
echo "📊 Files found:"
|
|
||||||
find artifacts -type f -exec ls -lh {} \;
|
|
||||||
echo ""
|
|
||||||
echo "📁 Full tree:"
|
|
||||||
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
|
|
||||||
|
|
||||||
- name: Flatten artifacts for release
|
mapfile -t downloaded_files < <(find release-files -type f)
|
||||||
run: |
|
if [ "${#downloaded_files[@]}" -eq 0 ]; then
|
||||||
echo "📦 Organizing artifacts..."
|
echo "No downloaded release artifacts were found."
|
||||||
mkdir -p release-files
|
|
||||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
|
||||||
echo ""
|
|
||||||
echo "✅ Files ready for release:"
|
|
||||||
ls -lh release-files/ || echo "⚠️ No files found in release-files"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Total files:"
|
|
||||||
file_count=$(find release-files -type f | wc -l)
|
|
||||||
echo "$file_count"
|
|
||||||
if [ "$file_count" -eq 0 ]; then
|
|
||||||
echo "Error: No installer/package files found for release"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create Release
|
for file in "${downloaded_files[@]}"; do
|
||||||
|
base_name="$(basename "$file")"
|
||||||
|
target_path="release-bundle/$base_name"
|
||||||
|
|
||||||
|
if [ -e "$target_path" ]; then
|
||||||
|
echo "Duplicate release asset name detected: $base_name"
|
||||||
|
echo "Conflicting file: $file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$file" "$target_path"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Validate release files
|
||||||
|
run: |
|
||||||
|
echo "Release files:"
|
||||||
|
find release-bundle -maxdepth 1 -type f -exec ls -lh {} \;
|
||||||
|
|
||||||
|
if [ ! -f release-bundle/files-windows-x64.zip ] || [ ! -f release-bundle/files-windows-x86.zip ] || [ ! -f release-bundle/files-linux-x64.zip ]; then
|
||||||
|
echo "Required payload zips are missing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_count=$(find release-bundle -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$file_count" -eq 0 ]; then
|
||||||
|
echo "No release files were produced."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create or Update Release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
tag: ${{ needs.prepare.outputs.tag }}
|
tag: ${{ needs.prepare.outputs.tag }}
|
||||||
name: ${{ needs.prepare.outputs.tag }}
|
name: ${{ needs.prepare.outputs.tag }}
|
||||||
commit: ${{ github.sha }}
|
|
||||||
allowUpdates: true
|
allowUpdates: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
|
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||||
artifacts: "release-files/**"
|
artifacts: 'release-bundle/*'
|
||||||
body: |
|
body: |
|
||||||
## Release ${{ needs.prepare.outputs.version }}
|
## Release ${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
### Windows
|
### Installers
|
||||||
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (完整版,包含 .NET 运行时)
|
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
|
||||||
- **LanMountainDesktop-Setup-{version}-x64-lite.exe** - 64-bit installer (轻量版,需安装 .NET 10 Runtime)
|
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
|
||||||
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (完整版,包含 .NET 运行时)
|
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
|
||||||
|
|
||||||
> **轻量版说明**:轻量版不包含 .NET 运行时,体积更小。首次运行前需安装 [.NET 10 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0)。
|
### Payload Archives
|
||||||
|
- `files-windows-x64.zip`
|
||||||
Installation: Double-click the .exe file and follow the wizard.
|
- `files-windows-x86.zip`
|
||||||
|
- `files-linux-x64.zip`
|
||||||
### Linux
|
|
||||||
- **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64)
|
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
- **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor
|
- macOS assets are best-effort and will not block the release.
|
||||||
- **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
|
||||||
|
|
||||||
See commits for changes.
|
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -512,3 +512,5 @@ nul
|
|||||||
/*.deb
|
/*.deb
|
||||||
/*.dmg
|
/*.dmg
|
||||||
/*.AppImage
|
/*.AppImage
|
||||||
|
/velopack-output-local-verify
|
||||||
|
/velopack-output-local
|
||||||
|
|||||||
805
.trae/documents/launcher_comprehensive_improvement_plan.md
Normal file
805
.trae/documents/launcher_comprehensive_improvement_plan.md
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
# LanMountainDesktop Launcher 全面改进计划
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本计划旨在将 LanMountainDesktop 的 Launcher 改进为符合原子化架构的独立启动器,参考 ClassIsland 的极简设计,同时保留阑山桌面的特色功能。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. **P0 (必须完成)**: 重写 Launcher 为极简模式,移除与主程序的耦合
|
||||||
|
2. **P1 (应该完成)**: 将 OOBE、Splash、更新、插件管理迁移到主程序
|
||||||
|
3. **P2 (推荐完成)**: 实现 Launcher 自更新机制
|
||||||
|
4. **P3 (可选优化)**: 性能优化和代码清理
|
||||||
|
5. **P4 (长期规划)**: 增强功能和可扩展性
|
||||||
|
|
||||||
|
## 当前问题
|
||||||
|
|
||||||
|
1. Launcher 是 Avalonia 应用,启动慢、内存占用高
|
||||||
|
2. Launcher 引用了 PluginSdk,与主程序有耦合
|
||||||
|
3. 主程序引用了 Launcher,构建关系复杂
|
||||||
|
4. Launcher 职责过多(OOBE + Splash + 更新 + 插件 + 启动)
|
||||||
|
5. 缺少 Launcher 自更新机制
|
||||||
|
6. GitHub Actions 工作流需要适配新的目录结构
|
||||||
|
|
||||||
|
## 改进后架构
|
||||||
|
|
||||||
|
```
|
||||||
|
安装根目录/
|
||||||
|
├── LanMountainDesktop.exe ← 启动器(唯一入口,极简,~100行代码)
|
||||||
|
├── app-1.0.0/ ← 版本目录
|
||||||
|
│ ├── .current ← 当前版本标记
|
||||||
|
│ ├── LanMountainDesktop.exe ← 主程序
|
||||||
|
│ └── ... (所有依赖)
|
||||||
|
└── .launcher/ ← 启动器数据(可选)
|
||||||
|
└── snapshots/ ← 版本快照
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细实施步骤
|
||||||
|
|
||||||
|
### P0: 基础架构重构
|
||||||
|
|
||||||
|
#### 1. 重写 Launcher 为极简模式
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/Program.cs`
|
||||||
|
|
||||||
|
**目标**:
|
||||||
|
- 代码量控制在 100 行以内
|
||||||
|
- 零外部依赖(不使用 Avalonia)
|
||||||
|
- 只负责:版本选择、启动主程序、清理旧版本
|
||||||
|
|
||||||
|
**完整实现代码**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// LanMountainDesktop.Launcher/Program.cs
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private const string HostExecutableName = "LanMountainDesktop.exe";
|
||||||
|
private const string HostExecutableNameLinux = "LanMountainDesktop";
|
||||||
|
|
||||||
|
[STAThread]
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
var rootDir = GetRootDirectory();
|
||||||
|
|
||||||
|
// 1. 查找最佳版本
|
||||||
|
var installation = FindBestVersion(rootDir);
|
||||||
|
if (installation == null)
|
||||||
|
{
|
||||||
|
ShowError("找不到有效的 LanMountainDesktop 版本,请重新安装。");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 清理旧版本(异步,不阻塞)
|
||||||
|
_ = Task.Run(() => CleanupOldVersions(rootDir));
|
||||||
|
|
||||||
|
// 3. 启动主程序
|
||||||
|
return LaunchHost(installation, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRootDirectory()
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(
|
||||||
|
Path.GetDirectoryName(Environment.ProcessPath) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindBestVersion(string rootDir)
|
||||||
|
{
|
||||||
|
var exeName = OperatingSystem.IsWindows()
|
||||||
|
? HostExecutableName
|
||||||
|
: HostExecutableNameLinux;
|
||||||
|
|
||||||
|
return Directory.GetDirectories(rootDir)
|
||||||
|
.Where(x => IsValidVersionDirectory(x, exeName))
|
||||||
|
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1)
|
||||||
|
.ThenByDescending(x => ParseVersion(Path.GetFileName(x)))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidVersionDirectory(string path, string exeName)
|
||||||
|
{
|
||||||
|
var dirName = Path.GetFileName(path);
|
||||||
|
return dirName.StartsWith("app-") &&
|
||||||
|
!File.Exists(Path.Combine(path, ".destroy")) &&
|
||||||
|
!File.Exists(Path.Combine(path, ".partial")) &&
|
||||||
|
File.Exists(Path.Combine(path, exeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Version ParseVersion(string dirName)
|
||||||
|
{
|
||||||
|
// app-1.0.0 or app-1.0.0-123
|
||||||
|
var parts = dirName.Split('-');
|
||||||
|
if (parts.Length >= 2 && Version.TryParse(parts[1], out var v))
|
||||||
|
return v;
|
||||||
|
return new Version(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CleanupOldVersions(string rootDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var oldVersions = Directory.GetDirectories(rootDir)
|
||||||
|
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
|
||||||
|
|
||||||
|
foreach (var dir in oldVersions)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 忽略清理失败 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LaunchHost(string installation, string[] args)
|
||||||
|
{
|
||||||
|
var exeName = OperatingSystem.IsWindows()
|
||||||
|
? HostExecutableName
|
||||||
|
: HostExecutableNameLinux;
|
||||||
|
var exePath = Path.Combine(installation, exeName);
|
||||||
|
|
||||||
|
// Linux/macOS: 确保可执行权限
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
EnsureExecutable(exePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = exePath,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(installation),
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var arg in args)
|
||||||
|
startInfo.ArgumentList.Add(arg);
|
||||||
|
|
||||||
|
// 传递环境变量
|
||||||
|
startInfo.EnvironmentVariables["LMD_PACKAGE_ROOT"] =
|
||||||
|
Path.GetDirectoryName(installation);
|
||||||
|
startInfo.EnvironmentVariables["LMD_VERSION"] =
|
||||||
|
Path.GetFileName(installation).Replace("app-", "");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(startInfo);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowError($"启动失败: {ex.Message}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureExecutable(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "chmod",
|
||||||
|
Arguments = $"+x \"{path}\"",
|
||||||
|
CreateNoWindow = true
|
||||||
|
})?.WaitForExit();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowError(string message)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
// Win32 MessageBox
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MessageBox(IntPtr.Zero, message, "LanMountainDesktop", 0x10);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 修改 Launcher 项目文件
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||||
|
|
||||||
|
**完整内容**:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- 图标资源 -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 移除主程序对 Launcher 的引用
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||||
|
|
||||||
|
**修改**: 删除以下行
|
||||||
|
```xml
|
||||||
|
<!-- 删除这一行 -->
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 修改主程序支持新架构
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/Program.cs`
|
||||||
|
|
||||||
|
**修改**: 添加环境变量读取
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 Program.cs 中添加
|
||||||
|
internal static class LaunchContext
|
||||||
|
{
|
||||||
|
public static string? PackageRoot =>
|
||||||
|
Environment.GetEnvironmentVariable("LMD_PACKAGE_ROOT");
|
||||||
|
public static string? Version =>
|
||||||
|
Environment.GetEnvironmentVariable("LMD_VERSION");
|
||||||
|
public static bool IsLaunchedByLauncher =>
|
||||||
|
!string.IsNullOrEmpty(PackageRoot);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1: 功能迁移
|
||||||
|
|
||||||
|
#### 5. 将 OOBE 迁移到主程序
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Oobe/OobeService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Oobe;
|
||||||
|
|
||||||
|
public class OobeService
|
||||||
|
{
|
||||||
|
private readonly string _oobeStatePath;
|
||||||
|
|
||||||
|
public OobeService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
_oobeStatePath = Path.Combine(appData, "LanMountainDesktop", ".oobe_completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsFirstRun()
|
||||||
|
{
|
||||||
|
return !File.Exists(_oobeStatePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkCompleted()
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(_oobeStatePath);
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(_oobeStatePath, DateTime.UtcNow.ToString("O"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LanMountainDesktop.Views.Oobe.OobeWindow"
|
||||||
|
Title="欢迎使用阑山桌面"
|
||||||
|
Width="800"
|
||||||
|
Height="600"
|
||||||
|
WindowStartupLocation="CenterScreen">
|
||||||
|
<Grid>
|
||||||
|
<!-- OOBE 界面内容 -->
|
||||||
|
<TextBlock Text="欢迎使用阑山桌面" FontSize="24" HorizontalAlignment="Center" Margin="0,50,0,0"/>
|
||||||
|
<Button Content="开始使用" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,50" Click="OnStartClick"/>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 OnFrameworkInitializationCompleted 中添加
|
||||||
|
private async Task InitializeOobeAsync()
|
||||||
|
{
|
||||||
|
var oobeService = new OobeService();
|
||||||
|
if (oobeService.IsFirstRun())
|
||||||
|
{
|
||||||
|
var oobeWindow = new Views.Oobe.OobeWindow();
|
||||||
|
await oobeWindow.ShowDialog();
|
||||||
|
oobeService.MarkCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. 将 Splash 迁移到主程序
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LanMountainDesktop.Views.Splash.SplashWindow"
|
||||||
|
Title="阑山桌面"
|
||||||
|
Width="400"
|
||||||
|
Height="300"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
SystemDecorations="None">
|
||||||
|
<Grid Background="{DynamicResource SystemAccentColor}">
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||||
|
<Image Source="/Assets/logo_nightly.png" Width="100" Height="100"/>
|
||||||
|
<TextBlock Text="阑山桌面" FontSize="20" Margin="0,20,0,0" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="StatusText" Text="正在启动..." Margin="0,10,0,0" HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在初始化时显示 Splash
|
||||||
|
private SplashWindow? _splashWindow;
|
||||||
|
|
||||||
|
private void ShowSplash()
|
||||||
|
{
|
||||||
|
_splashWindow = new SplashWindow();
|
||||||
|
_splashWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSplash()
|
||||||
|
{
|
||||||
|
_splashWindow?.Close();
|
||||||
|
_splashWindow = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. 将更新逻辑迁移到主程序
|
||||||
|
|
||||||
|
**新建目录**: `LanMountainDesktop/Services/Update/`
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Update/UpdateService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public class UpdateService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly string _currentVersion;
|
||||||
|
|
||||||
|
public UpdateService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop");
|
||||||
|
_currentVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateCheckResult> CheckForUpdateAsync(UpdateChannel channel)
|
||||||
|
{
|
||||||
|
// 调用 GitHub Release API
|
||||||
|
var releases = await _httpClient.GetFromJsonAsync<List<GitHubRelease>>(
|
||||||
|
"https://api.github.com/repos/ClassIsland/LanMountainDesktop/releases");
|
||||||
|
|
||||||
|
var latest = channel == UpdateChannel.Stable
|
||||||
|
? releases?.FirstOrDefault(r => !r.Prerelease)
|
||||||
|
: releases?.FirstOrDefault();
|
||||||
|
|
||||||
|
if (latest == null)
|
||||||
|
return new UpdateCheckResult { HasUpdate = false };
|
||||||
|
|
||||||
|
var latestVersion = latest.TagName.TrimStart('v');
|
||||||
|
var hasUpdate = new Version(latestVersion) > new Version(_currentVersion);
|
||||||
|
|
||||||
|
return new UpdateCheckResult
|
||||||
|
{
|
||||||
|
HasUpdate = hasUpdate,
|
||||||
|
Version = latestVersion,
|
||||||
|
DownloadUrl = latest.Assets.FirstOrDefault()?.BrowserDownloadUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateCheckResult
|
||||||
|
{
|
||||||
|
public bool HasUpdate { get; set; }
|
||||||
|
public string? Version { get; set; }
|
||||||
|
public string? DownloadUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdateChannel { Stable, Preview }
|
||||||
|
|
||||||
|
public class GitHubRelease
|
||||||
|
{
|
||||||
|
public string TagName { get; set; } = "";
|
||||||
|
public bool Prerelease { get; set; }
|
||||||
|
public List<GitHubAsset> Assets { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GitHubAsset
|
||||||
|
{
|
||||||
|
public string BrowserDownloadUrl { get; set; } = "";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. 将插件管理迁移到主程序
|
||||||
|
|
||||||
|
**新建目录**: `LanMountainDesktop/Services/Plugins/`
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace LanMountainDesktop.Services.Plugins;
|
||||||
|
|
||||||
|
public class PluginUpdateService
|
||||||
|
{
|
||||||
|
private readonly string _pluginsDirectory;
|
||||||
|
|
||||||
|
public PluginUpdateService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
_pluginsDirectory = Path.Combine(appData, "LanMountainDesktop", "plugins");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckAndUpdatePluginsAsync()
|
||||||
|
{
|
||||||
|
// 检查插件更新
|
||||||
|
// 下载并安装更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2: 自更新机制
|
||||||
|
|
||||||
|
#### 9. 实现 Launcher 自更新
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop.Launcher/Program.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 Main 方法开头添加自更新检查
|
||||||
|
private static void CheckForLauncherUpdate()
|
||||||
|
{
|
||||||
|
var rootDir = GetRootDirectory();
|
||||||
|
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
|
||||||
|
|
||||||
|
if (File.Exists(updatePath))
|
||||||
|
{
|
||||||
|
// 有新版本 Launcher,替换自身
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentPath = Environment.ProcessPath;
|
||||||
|
var backupPath = currentPath + ".old";
|
||||||
|
|
||||||
|
// 重命名当前版本
|
||||||
|
if (File.Exists(backupPath))
|
||||||
|
File.Delete(backupPath);
|
||||||
|
File.Move(currentPath!, backupPath);
|
||||||
|
|
||||||
|
// 移动新版本
|
||||||
|
File.Move(updatePath, currentPath!);
|
||||||
|
|
||||||
|
// 删除备份
|
||||||
|
File.Delete(backupPath);
|
||||||
|
|
||||||
|
// 重启自己
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = currentPath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 回滚
|
||||||
|
Console.Error.WriteLine($"Launcher 更新失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10. 主程序支持更新 Launcher
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public class LauncherUpdateService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public LauncherUpdateService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateLauncherAsync(string downloadUrl)
|
||||||
|
{
|
||||||
|
var rootDir = LaunchContext.PackageRoot
|
||||||
|
?? Path.GetDirectoryName(Environment.ProcessPath)!;
|
||||||
|
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
|
||||||
|
|
||||||
|
// 下载新版本
|
||||||
|
var response = await _httpClient.GetAsync(downloadUrl);
|
||||||
|
await using var fs = File.Create(updatePath);
|
||||||
|
await response.Content.CopyToAsync(fs);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RestartWithNewLauncher()
|
||||||
|
{
|
||||||
|
var launcherPath = Path.Combine(
|
||||||
|
LaunchContext.PackageRoot ?? "",
|
||||||
|
"LanMountainDesktop.exe");
|
||||||
|
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = launcherPath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 退出主程序,让 Launcher 接管
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3: 清理旧代码
|
||||||
|
|
||||||
|
#### 11. 删除文件清单
|
||||||
|
|
||||||
|
**删除以下文件/目录**:
|
||||||
|
|
||||||
|
```
|
||||||
|
LanMountainDesktop.Launcher/
|
||||||
|
├── App.axaml ← 删除
|
||||||
|
├── App.axaml.cs ← 删除
|
||||||
|
├── Views/ ← 删除整个目录
|
||||||
|
│ ├── OobeWindow.axaml
|
||||||
|
│ ├── OobeWindow.axaml.cs
|
||||||
|
│ ├── SplashWindow.axaml
|
||||||
|
│ └── SplashWindow.axaml.cs
|
||||||
|
├── Services/ ← 删除大部分
|
||||||
|
│ ├── LauncherFlowCoordinator.cs ← 删除
|
||||||
|
│ ├── OobeStateService.cs ← 删除
|
||||||
|
│ ├── UpdateCheckService.cs ← 删除
|
||||||
|
│ ├── UpdateEngineService.cs ← 删除
|
||||||
|
│ ├── PluginInstallerService.cs ← 删除
|
||||||
|
│ └── PluginUpgradeQueueService.cs ← 删除
|
||||||
|
└── Models/ ← 删除(如不再需要)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P4: GitHub Actions 工作流修改
|
||||||
|
|
||||||
|
#### 12. 修改 release.yml
|
||||||
|
|
||||||
|
**关键修改点**:
|
||||||
|
|
||||||
|
1. **Launcher 单独编译**:
|
||||||
|
```yaml
|
||||||
|
- name: Publish Launcher
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./publish/launcher-win-x64 `
|
||||||
|
--self-contained `
|
||||||
|
-r win-x64 `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:PublishTrimmed=false `
|
||||||
|
-p:DebugType=none
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **目录结构调整**:
|
||||||
|
```yaml
|
||||||
|
- name: Restructure for Launcher
|
||||||
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$publishDir = "publish/windows-x64"
|
||||||
|
$launcherDir = "publish/launcher-win-x64"
|
||||||
|
$appDir = "app-$version"
|
||||||
|
|
||||||
|
# 创建新结构
|
||||||
|
$newStructure = "publish-launcher/windows-x64"
|
||||||
|
New-Item -ItemType Directory -Path $newStructure -Force
|
||||||
|
|
||||||
|
# 移动主程序到 app-{version}/
|
||||||
|
$appPath = Join-Path $newStructure $appDir
|
||||||
|
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||||
|
|
||||||
|
# 复制 Launcher 到根目录
|
||||||
|
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
|
||||||
|
# 创建 .current 标记
|
||||||
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Linux/macOS 同样调整**:
|
||||||
|
- Linux: 修改 DEB 打包流程
|
||||||
|
- macOS: 修改 DMG 打包流程
|
||||||
|
|
||||||
|
#### 13. 修改 build.yml
|
||||||
|
|
||||||
|
**修改**: 移除 Launcher 相关构建步骤,因为 Launcher 现在完全独立
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P5: 图标资源处理
|
||||||
|
|
||||||
|
#### 14. Launcher 图标配置
|
||||||
|
|
||||||
|
**方案**: 使用链接方式引用主程序图标
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 链接主程序的图标 -->
|
||||||
|
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 15. 安装程序配置
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/installer/LanMountainDesktop.iss` (Inno Setup)
|
||||||
|
|
||||||
|
**关键配置**:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Setup]
|
||||||
|
AppName=阑山桌面
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
DefaultDirName={autopf}\LanMountainDesktop
|
||||||
|
OutputBaseFilename=LanMountainDesktop-Setup-{#MyAppVersion}-x64
|
||||||
|
SetupIconFile=..\Assets\logo_nightly.ico
|
||||||
|
UninstallDisplayIcon={app}\LanMountainDesktop.exe
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
; Launcher
|
||||||
|
Source: "..\..\publish\windows-x64\LanMountainDesktop.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
; 主程序版本目录
|
||||||
|
Source: "..\..\publish\windows-x64\app-{#MyAppVersion}\*"; DestDir: "{app}\app-{#MyAppVersion}"; Flags: ignoreversion recursesubdirs
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
; 桌面快捷方式
|
||||||
|
Name: "{autodesktop}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
|
||||||
|
; 开始菜单
|
||||||
|
Name: "{group}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更清单
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Launcher/Program.cs` - 完全重写
|
||||||
|
2. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 简化依赖
|
||||||
|
3. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
|
||||||
|
4. `LanMountainDesktop/Program.cs` - 添加 LaunchContext
|
||||||
|
5. `LanMountainDesktop/App.axaml.cs` - 添加 OOBE/Splash/更新入口
|
||||||
|
6. `.github/workflows/release.yml` - 调整打包流程
|
||||||
|
7. `.github/workflows/build.yml` - 适配新构建流程
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop/Services/Oobe/OobeService.cs`
|
||||||
|
2. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
|
||||||
|
3. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml.cs`
|
||||||
|
4. `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
|
||||||
|
5. `LanMountainDesktop/Views/Splash/SplashWindow.axaml.cs`
|
||||||
|
6. `LanMountainDesktop/Services/Update/UpdateService.cs`
|
||||||
|
7. `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
|
||||||
|
8. `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Launcher/App.axaml`
|
||||||
|
2. `LanMountainDesktop.Launcher/App.axaml.cs`
|
||||||
|
3. `LanMountainDesktop.Launcher/Views/` 目录
|
||||||
|
4. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||||
|
5. `LanMountainDesktop.Launcher/Services/OobeStateService.cs`
|
||||||
|
6. `LanMountainDesktop.Launcher/Services/UpdateCheckService.cs`
|
||||||
|
7. `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
|
||||||
|
8. `LanMountainDesktop.Launcher/Services/PluginInstallerService.cs`
|
||||||
|
9. `LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与回滚方案
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
1. **启动失败**: 新 Launcher 可能有 bug 导致无法启动
|
||||||
|
2. **更新中断**: 更新逻辑迁移可能导致更新失败
|
||||||
|
3. **图标丢失**: 图标配置错误导致快捷方式无图标
|
||||||
|
|
||||||
|
### 回滚方案
|
||||||
|
|
||||||
|
1. 保留原 Launcher 代码分支
|
||||||
|
2. 准备紧急修复版本
|
||||||
|
3. 用户可手动下载完整安装包恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- [ ] Launcher 能正常启动主程序
|
||||||
|
- [ ] 版本选择逻辑正确
|
||||||
|
- [ ] 旧版本清理正常
|
||||||
|
- [ ] OOBE 流程正常
|
||||||
|
- [ ] Splash 显示正常
|
||||||
|
- [ ] 更新检查正常
|
||||||
|
- [ ] 插件安装正常
|
||||||
|
- [ ] GitHub Actions 打包成功
|
||||||
|
- [ ] 安装程序图标正常
|
||||||
|
- [ ] 快捷方式图标正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施顺序建议
|
||||||
|
|
||||||
|
### 第一阶段(立即实施)
|
||||||
|
1. 重写 Launcher Program.cs
|
||||||
|
2. 修改 Launcher.csproj
|
||||||
|
3. 移除主程序对 Launcher 的引用
|
||||||
|
4. 测试基本启动功能
|
||||||
|
|
||||||
|
### 第二阶段(功能迁移)
|
||||||
|
1. 迁移 OOBE 到主程序
|
||||||
|
2. 迁移 Splash 到主程序
|
||||||
|
3. 迁移更新逻辑到主程序
|
||||||
|
4. 迁移插件管理到主程序
|
||||||
|
|
||||||
|
### 第三阶段(CI/CD)
|
||||||
|
1. 修改 release.yml
|
||||||
|
2. 修改 build.yml
|
||||||
|
3. 测试打包流程
|
||||||
|
4. 验证安装程序
|
||||||
|
|
||||||
|
### 第四阶段(优化)
|
||||||
|
1. 实现 Launcher 自更新
|
||||||
|
2. 性能优化
|
||||||
|
3. 清理旧代码
|
||||||
717
.trae/documents/launcher_improved_plan_v2.md
Normal file
717
.trae/documents/launcher_improved_plan_v2.md
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
# LanMountainDesktop Launcher 改进计划 V2
|
||||||
|
|
||||||
|
## 核心设计理念
|
||||||
|
|
||||||
|
**Launcher 是核心协调器,不是极简启动器**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Launcher 职责定位 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Launcher 负责(启动前 & 退出后): │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • OOBE 首次引导 │ │
|
||||||
|
│ │ • 启动动画 (Splash) │ │
|
||||||
|
│ │ • 插件安装 │ │
|
||||||
|
│ │ • 插件更新 │ │
|
||||||
|
│ │ • 应用增量更新安装(不是下载!) │ │
|
||||||
|
│ │ • 应用静默更新安装 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 主程序负责(运行时): │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 多线程下载(有完整 Downloader) │ │
|
||||||
|
│ │ • 更新渠道切换 │ │
|
||||||
|
│ │ • 下载管理 │ │
|
||||||
|
│ │ • 与 Launcher 通讯(启动进度) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 关键优势: │
|
||||||
|
│ • Launcher 在应用启动前运行 → 可以安装更新而不担心文件占用 │
|
||||||
|
│ • Launcher 在应用退出后运行 → 可以完成待处理的安装任务 │
|
||||||
|
│ • 主程序专注下载 → 利用完整的多线程下载器提高效率 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 为什么保留 Avalonia?
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 保留 Avalonia 的理由 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. 启动画面 (Splash) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 需要显示启动进度 │ │
|
||||||
|
│ │ • 需要显示品牌 Logo │ │
|
||||||
|
│ │ • 需要流畅的动画效果 │ │
|
||||||
|
│ │ • 纯 Win32 实现复杂且不易维护 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 2. OOBE 首次引导 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 需要多步骤向导界面 │ │
|
||||||
|
│ │ • 需要丰富的交互控件 │ │
|
||||||
|
│ │ • 需要与主程序一致的视觉风格 │ │
|
||||||
|
│ │ • Avalonia 提供完整的 UI 框架 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 3. 与主程序的技术栈一致 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 共享主题和资源 │ │
|
||||||
|
│ │ • 共享控件和样式 │ │
|
||||||
|
│ │ • 便于维护和迭代 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 改进后的架构设计
|
||||||
|
|
||||||
|
### 目录结构(保持不变)
|
||||||
|
|
||||||
|
```
|
||||||
|
安装根目录/
|
||||||
|
├── LanMountainDesktop.exe ← Launcher(Avalonia 应用)
|
||||||
|
├── app-1.0.0/ ← 版本目录
|
||||||
|
│ ├── .current ← 当前版本标记
|
||||||
|
│ ├── LanMountainDesktop.exe ← 主程序
|
||||||
|
│ └── ... (所有依赖)
|
||||||
|
└── .launcher/ ← Launcher 数据目录
|
||||||
|
├── update/ ← 更新缓存
|
||||||
|
│ └── incoming/ ← 下载的更新包(主程序下载到这里)
|
||||||
|
└── snapshots/ ← 版本快照
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心流程设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 启动流程(含通讯机制) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. 用户启动 LanMountainDesktop.exe (Launcher) │
|
||||||
|
│ ↓ │
|
||||||
|
│ 2. Launcher 检查是否有待处理的更新安装 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 3. 有更新?──Yes──▶ 显示 Splash "正在安装更新..." │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ No 安装更新(增量/静默) │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ 4. 检查是否首次运行 ──Yes──▶ 显示 OOBE 窗口 │
|
||||||
|
│ ↓ No ↓ │
|
||||||
|
│ 5. 显示 Splash "正在启动..." 完成 OOBE │
|
||||||
|
│ ↓ │
|
||||||
|
│ 6. 启动主程序进程(带通讯参数) │
|
||||||
|
│ ↓ │
|
||||||
|
│ 7. Launcher 保持运行,监听主程序进度 ─────── IPC 通讯 ───────▶ 主程序 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 8. 主程序报告启动进度 ─────── IPC 通讯 ───────▶ Launcher 更新 Splash │
|
||||||
|
│ ↓ │
|
||||||
|
│ 9. 主程序完全启动 ──Yes──▶ Launcher 关闭 Splash,进入后台/退出 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 退出流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 退出流程(处理待安装任务) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. 主程序准备退出 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 2. 检查是否有待安装的更新/插件 ──Yes──▶ 重启 Launcher 并传递参数 │
|
||||||
|
│ ↓ No ↓ │
|
||||||
|
│ 3. 正常退出 Launcher 在应用退出后运行 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 安装待处理的任务 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 完成后再次启动主程序 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Launcher 与主程序的通讯机制
|
||||||
|
|
||||||
|
### IPC 方案选择
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ IPC 通讯方案 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 方案 1: 命令行参数 + 退出码(推荐用于启动阶段) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Launcher 启动主程序: │ │
|
||||||
|
│ │ LanMountainDesktop.exe --launcher-pid 12345 --ipc-port 50000 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 主程序通过命名管道/HTTP 与 Launcher 通讯 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 方案 2: 命名管道(推荐用于进度报告) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
|
||||||
|
│ │ 主程序连接并发送进度消息 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 消息格式: JSON │ │
|
||||||
|
│ │ {"stage": "initializing", "progress": 30, "message": "加载设置..."} │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 方案 3: 共享内存/文件(简单状态同步) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Launcher 和主程序读写同一个状态文件 │ │
|
||||||
|
│ │ .launcher/state/startup_status.json │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通讯协议设计
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 共享契约(LanMountainDesktop.Shared.Contracts)
|
||||||
|
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
public enum StartupStage
|
||||||
|
{
|
||||||
|
Initializing,
|
||||||
|
LoadingSettings,
|
||||||
|
LoadingPlugins,
|
||||||
|
InitializingUI,
|
||||||
|
Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StartupProgressMessage
|
||||||
|
{
|
||||||
|
public StartupStage Stage { get; init; }
|
||||||
|
public int ProgressPercent { get; init; } // 0-100
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LauncherIpc
|
||||||
|
{
|
||||||
|
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||||
|
public const string EnvironmentVariablePrefix = "LMD_";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细实施步骤
|
||||||
|
|
||||||
|
### P0: 架构调整(核心)
|
||||||
|
|
||||||
|
#### 1. 调整 Launcher 项目引用
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
- 保留 Avalonia 依赖
|
||||||
|
- 移除 PluginSdk 引用(Launcher 不需要)
|
||||||
|
- 添加 Shared.Contracts 引用(用于 IPC)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationIcon>Assetsogo_nightly.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 保留 Avalonia -->
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||||
|
|
||||||
|
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- 图标资源 -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 移除主程序对 Launcher 的引用
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||||
|
|
||||||
|
**修改**: 删除 Launcher 引用
|
||||||
|
```xml
|
||||||
|
<!-- 删除 -->
|
||||||
|
<!-- <ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" /> -->
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 创建 IPC 通讯契约
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
public enum StartupStage
|
||||||
|
{
|
||||||
|
Initializing,
|
||||||
|
LoadingSettings,
|
||||||
|
LoadingPlugins,
|
||||||
|
InitializingUI,
|
||||||
|
Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StartupProgressMessage
|
||||||
|
{
|
||||||
|
public StartupStage Stage { get; init; }
|
||||||
|
public int ProgressPercent { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LauncherIpcConstants
|
||||||
|
{
|
||||||
|
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||||
|
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
|
||||||
|
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
|
||||||
|
public const string VersionEnvVar = "LMD_VERSION";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P1: Launcher 端实现
|
||||||
|
|
||||||
|
#### 4. 实现 IPC 服务端
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
|
|
||||||
|
public class LauncherIpcServer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private NamedPipeServerStream? _pipeServer;
|
||||||
|
private readonly Action<StartupProgressMessage> _onProgress;
|
||||||
|
|
||||||
|
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||||
|
{
|
||||||
|
_onProgress = onProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipeServer = new NamedPipeServerStream(
|
||||||
|
LauncherIpcConstants.PipeName,
|
||||||
|
PipeDirection.In,
|
||||||
|
1,
|
||||||
|
PipeTransmissionMode.Message);
|
||||||
|
|
||||||
|
await _pipeServer.WaitForConnectionAsync(_cts.Token);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(_pipeServer);
|
||||||
|
var json = await reader.ReadToEndAsync(_cts.Token);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||||
|
if (message != null)
|
||||||
|
{
|
||||||
|
_onProgress(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pipeServer.Disconnect();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"IPC error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_pipeServer?.Dispose();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 修改 Launcher 启动流程
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<LauncherResult> RunAsync()
|
||||||
|
{
|
||||||
|
// 1. 清理旧版本
|
||||||
|
_deploymentLocator.CleanupDestroyedDeployments();
|
||||||
|
|
||||||
|
// 2. 检查并安装待处理的更新(主程序下载的)
|
||||||
|
var pendingUpdate = _updateEngine.CheckPendingUpdate();
|
||||||
|
if (pendingUpdate.HasUpdate)
|
||||||
|
{
|
||||||
|
_splashWindow?.UpdateStatus("正在安装更新...");
|
||||||
|
var updateResult = await _updateEngine.ApplyPendingUpdateAsync();
|
||||||
|
if (!updateResult.Success)
|
||||||
|
{
|
||||||
|
return updateResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查并安装待处理的插件更新
|
||||||
|
var pendingPlugins = _pluginUpgradeQueueService.CheckPendingUpgrades();
|
||||||
|
if (pendingPlugins.HasUpgrades)
|
||||||
|
{
|
||||||
|
_splashWindow?.UpdateStatus("正在更新插件...");
|
||||||
|
var pluginResult = _pluginUpgradeQueueService.ApplyPendingUpgrades();
|
||||||
|
if (!pluginResult.Success)
|
||||||
|
{
|
||||||
|
return pluginResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. OOBE
|
||||||
|
if (_oobeStateService.IsFirstRun())
|
||||||
|
{
|
||||||
|
_splashWindow?.Hide();
|
||||||
|
foreach (var step in _oobeSteps)
|
||||||
|
{
|
||||||
|
await step.RunAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
_splashWindow?.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 启动 IPC 服务端监听主程序进度
|
||||||
|
using var ipcServer = new LauncherIpcServer(msg =>
|
||||||
|
{
|
||||||
|
_splashWindow?.UpdateProgress(msg.ProgressPercent, msg.Message);
|
||||||
|
});
|
||||||
|
_ = ipcServer.StartAsync();
|
||||||
|
|
||||||
|
// 6. 启动主程序
|
||||||
|
_splashWindow?.UpdateStatus("正在启动...");
|
||||||
|
var hostResult = LaunchHostWithIpc();
|
||||||
|
if (!hostResult.Success)
|
||||||
|
{
|
||||||
|
return hostResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 等待主程序报告就绪或超时
|
||||||
|
await WaitForHostReadyOrTimeoutAsync(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
return new LauncherResult { Success = true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2: 主程序端实现
|
||||||
|
|
||||||
|
#### 6. 实现 IPC 客户端
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Launcher;
|
||||||
|
|
||||||
|
public class LauncherIpcClient : IDisposable
|
||||||
|
{
|
||||||
|
private NamedPipeClientStream? _pipeClient;
|
||||||
|
|
||||||
|
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_pipeClient = new NamedPipeClientStream(
|
||||||
|
".",
|
||||||
|
LauncherIpcConstants.PipeName,
|
||||||
|
PipeDirection.Out);
|
||||||
|
|
||||||
|
await _pipeClient.ConnectAsync(5000, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||||
|
{
|
||||||
|
if (_pipeClient?.IsConnected != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(message);
|
||||||
|
using var writer = new StreamWriter(_pipeClient, leaveOpen: true);
|
||||||
|
await writer.WriteAsync(json);
|
||||||
|
await writer.FlushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_pipeClient?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. 主程序启动时报告进度
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override async void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
// 检查是否从 Launcher 启动
|
||||||
|
var launcherPid = Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar);
|
||||||
|
if (!string.IsNullOrEmpty(launcherPid))
|
||||||
|
{
|
||||||
|
// 连接到 Launcher 的 IPC 服务端
|
||||||
|
_launcherIpc = new LauncherIpcClient();
|
||||||
|
await _launcherIpc.ConnectAsync();
|
||||||
|
|
||||||
|
// 报告启动进度
|
||||||
|
await _launcherIpc.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.Initializing,
|
||||||
|
ProgressPercent = 10,
|
||||||
|
Message = "正在初始化..."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化设置
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.LoadingSettings,
|
||||||
|
ProgressPercent = 30,
|
||||||
|
Message = "正在加载设置..."
|
||||||
|
});
|
||||||
|
InitializeSettings();
|
||||||
|
|
||||||
|
// 加载插件
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.LoadingPlugins,
|
||||||
|
ProgressPercent = 50,
|
||||||
|
Message = "正在加载插件..."
|
||||||
|
});
|
||||||
|
await InitializePluginsAsync();
|
||||||
|
|
||||||
|
// 初始化 UI
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.InitializingUI,
|
||||||
|
ProgressPercent = 80,
|
||||||
|
Message = "正在初始化界面..."
|
||||||
|
});
|
||||||
|
InitializeUI();
|
||||||
|
|
||||||
|
// 就绪
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.Ready,
|
||||||
|
ProgressPercent = 100,
|
||||||
|
Message = "就绪"
|
||||||
|
});
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P3: 更新流程整合
|
||||||
|
|
||||||
|
#### 8. 主程序下载更新
|
||||||
|
|
||||||
|
**主程序职责**:
|
||||||
|
```csharp
|
||||||
|
// 主程序中的更新服务
|
||||||
|
public class AppUpdateService
|
||||||
|
{
|
||||||
|
public async Task DownloadUpdateAsync(string version, string downloadUrl)
|
||||||
|
{
|
||||||
|
// 使用多线程下载器下载更新包
|
||||||
|
var downloader = new MultiThreadedDownloader();
|
||||||
|
var targetPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"LanMountainDesktop",
|
||||||
|
".launcher",
|
||||||
|
"update",
|
||||||
|
"incoming",
|
||||||
|
$"update-{version}.zip");
|
||||||
|
|
||||||
|
await downloader.DownloadAsync(downloadUrl, targetPath);
|
||||||
|
|
||||||
|
// 标记为待安装
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(Path.GetDirectoryName(targetPath)!, ".pending"),
|
||||||
|
version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9. Launcher 安装更新
|
||||||
|
|
||||||
|
**Launcher 职责**:
|
||||||
|
```csharp
|
||||||
|
// Launcher 中的更新安装服务
|
||||||
|
public class UpdateInstallationService
|
||||||
|
{
|
||||||
|
public async Task<InstallResult> InstallPendingUpdateAsync()
|
||||||
|
{
|
||||||
|
var pendingPath = Path.Combine(
|
||||||
|
_appRoot,
|
||||||
|
".launcher",
|
||||||
|
"update",
|
||||||
|
"incoming",
|
||||||
|
".pending");
|
||||||
|
|
||||||
|
if (!File.Exists(pendingPath))
|
||||||
|
return InstallResult.NoUpdate;
|
||||||
|
|
||||||
|
var version = File.ReadAllText(pendingPath);
|
||||||
|
var updatePackagePath = Path.Combine(
|
||||||
|
Path.GetDirectoryName(pendingPath)!,
|
||||||
|
$"update-{version}.zip");
|
||||||
|
|
||||||
|
// 创建新版本目录
|
||||||
|
var newVersionDir = Path.Combine(_appRoot, $"app-{version}");
|
||||||
|
Directory.CreateDirectory(newVersionDir);
|
||||||
|
File.WriteAllText(Path.Combine(newVersionDir, ".partial"), "");
|
||||||
|
|
||||||
|
// 解压更新包
|
||||||
|
ZipFile.ExtractToDirectory(updatePackagePath, newVersionDir);
|
||||||
|
|
||||||
|
// 验证文件完整性
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// 切换版本标记
|
||||||
|
var currentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||||
|
if (currentDir != null)
|
||||||
|
{
|
||||||
|
File.Delete(Path.Combine(currentDir, ".current"));
|
||||||
|
File.WriteAllText(Path.Combine(currentDir, ".destroy"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(newVersionDir, ".current"), "");
|
||||||
|
File.Delete(Path.Combine(newVersionDir, ".partial"));
|
||||||
|
|
||||||
|
// 清理待安装标记
|
||||||
|
File.Delete(pendingPath);
|
||||||
|
File.Delete(updatePackagePath);
|
||||||
|
|
||||||
|
return InstallResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P4: GitHub Actions 工作流
|
||||||
|
|
||||||
|
#### 10. 修改 release.yml
|
||||||
|
|
||||||
|
**关键修改点**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 1. Launcher 单独编译(保留 Avalonia)
|
||||||
|
- name: Publish Launcher
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./publish/launcher-win-x64 `
|
||||||
|
--self-contained `
|
||||||
|
-r win-x64 `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:DebugType=none
|
||||||
|
|
||||||
|
# 2. 目录结构调整
|
||||||
|
- name: Restructure for Launcher
|
||||||
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$publishDir = "publish/windows-x64"
|
||||||
|
$launcherDir = "publish/launcher-win-x64"
|
||||||
|
$appDir = "app-$version"
|
||||||
|
|
||||||
|
# 创建新结构
|
||||||
|
$newStructure = "publish-launcher/windows-x64"
|
||||||
|
New-Item -ItemType Directory -Path $newStructure -Force
|
||||||
|
|
||||||
|
# 移动主程序到 app-{version}/
|
||||||
|
$appPath = Join-Path $newStructure $appDir
|
||||||
|
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||||
|
|
||||||
|
# 复制 Launcher 到根目录
|
||||||
|
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
|
||||||
|
# 创建 .current 标记
|
||||||
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件变更清单
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 调整引用
|
||||||
|
2. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
|
||||||
|
3. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` - 添加 IPC 和更新安装
|
||||||
|
4. `LanMountainDesktop/App.axaml.cs` - 添加 IPC 客户端和进度报告
|
||||||
|
5. `.github/workflows/release.yml` - 调整打包流程
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
|
||||||
|
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
|
||||||
|
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
|
||||||
|
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
|
||||||
|
1. 主程序对 Launcher 的项目引用(已存在)
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
### 第一阶段:基础架构
|
||||||
|
1. 创建 IPC 契约(Shared.Contracts)
|
||||||
|
2. 调整 Launcher 项目引用
|
||||||
|
3. 移除主程序对 Launcher 的引用
|
||||||
|
4. 测试基本启动
|
||||||
|
|
||||||
|
### 第二阶段:IPC 实现
|
||||||
|
1. 实现 Launcher IPC 服务端
|
||||||
|
2. 实现主程序 IPC 客户端
|
||||||
|
3. 测试进度报告
|
||||||
|
|
||||||
|
### 第三阶段:更新流程
|
||||||
|
1. 主程序实现下载功能
|
||||||
|
2. Launcher 实现安装功能
|
||||||
|
3. 测试完整更新流程
|
||||||
|
|
||||||
|
### 第四阶段:CI/CD
|
||||||
|
1. 修改 GitHub Actions
|
||||||
|
2. 测试打包流程
|
||||||
|
3. 验证安装程序
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- [ ] Launcher 能正常启动主程序
|
||||||
|
- [ ] Launcher 显示 Splash 并接收进度更新
|
||||||
|
- [ ] 主程序能向 Launcher 报告启动进度
|
||||||
|
- [ ] 主程序能下载更新
|
||||||
|
- [ ] Launcher 能安装待处理的更新
|
||||||
|
- [ ] OOBE 流程正常
|
||||||
|
- [ ] 插件更新流程正常
|
||||||
|
- [ ] GitHub Actions 打包成功
|
||||||
|
- [ ] 安装程序图标正常
|
||||||
|
- [ ] 快捷方式图标正常
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Launcher OOBE and Elevation Hardening Checklist
|
||||||
|
|
||||||
|
- [ ] New install shows OOBE once.
|
||||||
|
- [ ] Same-user reinstall does not show OOBE again.
|
||||||
|
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||||
|
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
||||||
|
- [ ] Default plugin install does not request UAC.
|
||||||
|
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||||
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Launcher OOBE and Elevation Hardening Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Stabilize the launcher startup path so that:
|
||||||
|
|
||||||
|
- OOBE does not reappear for the same Windows user after reinstall/upgrade.
|
||||||
|
- Normal startup, OOBE, update checks, incremental downloads, and default plugin installs do not trigger unexpected UAC prompts.
|
||||||
|
- Only the approved elevation paths remain allowed.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Launcher OOBE state handling
|
||||||
|
- launch source classification
|
||||||
|
- elevation boundary cleanup
|
||||||
|
- plugin install default behavior
|
||||||
|
- diagnostic logging and troubleshooting guidance
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- OOBE state is stored as a per-user truth source at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||||
|
- `first_run_completed` is treated as a legacy compatibility marker only.
|
||||||
|
- `launchSource` values are treated as:
|
||||||
|
- `normal`
|
||||||
|
- `postinstall`
|
||||||
|
- `apply-update`
|
||||||
|
- `plugin-install`
|
||||||
|
- `debug-preview`
|
||||||
|
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||||
|
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||||
|
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||||
|
- Allowed elevation paths are limited to:
|
||||||
|
- the installer itself
|
||||||
|
- full installer update application
|
||||||
|
- user-confirmed legacy uninstall
|
||||||
|
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Same-user reinstall does not re-enter OOBE.
|
||||||
|
- Missing or damaged OOBE state does not silently bounce the user back into OOBE loops.
|
||||||
|
- Default plugin installation path never triggers surprise UAC.
|
||||||
|
- Logs can explain why OOBE was shown or suppressed and why elevation was or was not requested.
|
||||||
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Launcher OOBE and Elevation Hardening Tasks
|
||||||
|
|
||||||
|
- [ ] Move OOBE state to a single per-user JSON source.
|
||||||
|
- [ ] Treat `first_run_completed` as legacy migration-only state.
|
||||||
|
- [ ] Add explicit `launchSource` handling for startup and maintenance flows.
|
||||||
|
- [ ] Suppress auto-OOBE for maintenance and elevated launch contexts.
|
||||||
|
- [ ] Remove default elevation from plugin installation into the user data scope.
|
||||||
|
- [ ] Add structured diagnostics for OOBE decisions and elevation reasons.
|
||||||
|
- [ ] Update launcher docs and troubleshooting guidance.
|
||||||
11
.trae/specs/launcher-upgrade/checklist.md
Normal file
11
.trae/specs/launcher-upgrade/checklist.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Launcher Upgrade Checklist
|
||||||
|
|
||||||
|
- [x] Build passes for `LanMountainDesktop.Launcher`.
|
||||||
|
- [x] `update check` command returns structured JSON result.
|
||||||
|
- [x] `plugin update` command returns structured JSON result.
|
||||||
|
- [x] Legacy plugin install arguments still execute.
|
||||||
|
- [x] OOBE and splash are implemented as separate windows.
|
||||||
|
- [x] Update and rollback logic use version directory markers.
|
||||||
|
|
||||||
|
- [ ] Treat `first_run_completed` as legacy-only compatibility data.
|
||||||
|
- [ ] Keep the authoritative OOBE state in `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||||
60
.trae/specs/launcher-upgrade/spec.md
Normal file
60
.trae/specs/launcher-upgrade/spec.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Launcher Upgrade Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
|
||||||
|
|
||||||
|
- OOBE first-run entry
|
||||||
|
- startup splash window
|
||||||
|
- silent/incremental/rollback update
|
||||||
|
- plugin install/update
|
||||||
|
|
||||||
|
## Scope (Phase 1)
|
||||||
|
|
||||||
|
- Avalonia GUI launcher with two windows:
|
||||||
|
- `OOBEWindow` (first run only)
|
||||||
|
- `SplashWindow` (every launch)
|
||||||
|
- Default command `launch`
|
||||||
|
- CLI commands:
|
||||||
|
- `update check|download|apply|rollback`
|
||||||
|
- `plugin install|update`
|
||||||
|
- Legacy compatibility:
|
||||||
|
- `--source --plugins-dir --result` still works for plugin install
|
||||||
|
|
||||||
|
## Update Behavior
|
||||||
|
|
||||||
|
- ClassIsland-style deployment folders:
|
||||||
|
- `app-<version>-<number>/`
|
||||||
|
- marker files `.current`, `.partial`, `.destroy`
|
||||||
|
- Signed file map:
|
||||||
|
- `files.json`
|
||||||
|
- `files.json.sig`
|
||||||
|
- `public-key.pem`
|
||||||
|
- Incremental update:
|
||||||
|
- `replace` from archive
|
||||||
|
- `reuse` from current deployment
|
||||||
|
- `delete` skip file in target deployment
|
||||||
|
- Rollback:
|
||||||
|
- snapshot metadata is written before apply
|
||||||
|
- automatic rollback on apply failure
|
||||||
|
- manual rollback via command
|
||||||
|
|
||||||
|
## OOBE and Splash
|
||||||
|
|
||||||
|
- OOBE is independent from splash.
|
||||||
|
- OOBE shows only:
|
||||||
|
- welcome text: `欢迎使用阑山桌面`
|
||||||
|
- arrow button for continue
|
||||||
|
- Splash shows only:
|
||||||
|
- app name: `阑山桌面`
|
||||||
|
|
||||||
|
## Extensibility
|
||||||
|
|
||||||
|
- `IOobeStep` for future multi-step OOBE
|
||||||
|
- `ISplashStageReporter` for future startup progress visualization
|
||||||
|
|
||||||
|
## Compatibility Addendum
|
||||||
|
|
||||||
|
- The current production OOBE state format is a per-user JSON file at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||||
|
- `first_run_completed` remains legacy compatibility data only.
|
||||||
|
- Same-user reinstall or upgrade should not re-enter OOBE.
|
||||||
12
.trae/specs/launcher-upgrade/tasks.md
Normal file
12
.trae/specs/launcher-upgrade/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Launcher Upgrade Tasks
|
||||||
|
|
||||||
|
- [x] Convert `LanMountainDesktop.Launcher` to Avalonia launcher entry.
|
||||||
|
- [x] Add OOBE window with first-run marker handling.
|
||||||
|
- [x] Add splash window for every startup.
|
||||||
|
- [x] Implement unified command parsing with default `launch`.
|
||||||
|
- [x] Keep legacy plugin install args compatibility.
|
||||||
|
- [x] Add plugin pending upgrade queue processing.
|
||||||
|
- [x] Implement incremental update apply with signed file map.
|
||||||
|
- [x] Implement snapshot-based rollback and manual rollback command.
|
||||||
|
- [x] Add update check/download/apply/rollback CLI commands.
|
||||||
|
- [x] Add launcher spec files under `.trae/specs/launcher-upgrade/`.
|
||||||
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
|
||||||
|
- [ ] `release.yml` uploads app payload artifacts for PDCC.
|
||||||
|
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
|
||||||
|
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
|
||||||
|
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
|
||||||
|
- [ ] Host can persist PDC payload into launcher incoming directory.
|
||||||
|
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
|
||||||
|
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
|
||||||
|
- [ ] CI run attached proving all release matrix jobs pass.
|
||||||
|
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
||||||
|
- [ ] Rollback verification report attached.
|
||||||
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# PDC Incremental Update Migration
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
||||||
|
|
||||||
|
## Stage 1 (Completed)
|
||||||
|
|
||||||
|
- Release workflow removed VeloPack-based release packaging.
|
||||||
|
- Signed FileMap path was restored as an interim release mechanism.
|
||||||
|
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
|
||||||
|
|
||||||
|
## Stage 2 (Current Implementation Target)
|
||||||
|
|
||||||
|
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
|
||||||
|
- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
|
||||||
|
- Keep GitHub Release installers and metadata as parallel distribution.
|
||||||
|
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
|
||||||
|
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
|
||||||
|
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
|
||||||
|
|
||||||
|
Expected S3 layout:
|
||||||
|
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
|
||||||
|
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
|
||||||
|
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
|
||||||
|
- `lanmountain/update/installers/<platform>/<arch>/*`
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `release.yml` includes PDCC publish steps and no Velopack steps.
|
||||||
|
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
|
||||||
|
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
|
||||||
|
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
|
||||||
|
- Launcher can apply both:
|
||||||
|
- legacy signed `files.json + update.zip`
|
||||||
|
- PDC FileMap object-repo payload.
|
||||||
|
- Rollback semantics remain unchanged.
|
||||||
|
|
||||||
|
## Deprecated Notes
|
||||||
|
|
||||||
|
- The following interim outputs are compatibility-only (not the long-term primary path):
|
||||||
|
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
||||||
|
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
||||||
|
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
||||||
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Remove VeloPack packaging from release workflow.
|
||||||
|
- [x] Keep signed FileMap path as interim compatibility fallback.
|
||||||
|
- [x] Remove launcher/runtime Velopack branching.
|
||||||
|
- [ ] Add `phainon.yml` for PDCC publish configuration.
|
||||||
|
- [ ] Add PDCC installation + publish steps in `release.yml`.
|
||||||
|
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
|
||||||
|
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
|
||||||
|
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
|
||||||
|
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
|
||||||
|
- [ ] Add PDC payload model into host update check result.
|
||||||
|
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
|
||||||
|
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
|
||||||
|
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.
|
||||||
12
.trae/specs/plugin-process-isolation/checklist.md
Normal file
12
.trae/specs/plugin-process-isolation/checklist.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] `plugin.json` 缺省时仍默认为 `in-proc`
|
||||||
|
- [x] 非法 `runtime.mode` 会给出清晰错误
|
||||||
|
- [x] SDK 中已有 Worker 入口和隔离运行模式的公共接口
|
||||||
|
- [x] IPC 契约已拆到独立工程,且不引用 Avalonia
|
||||||
|
- [x] IPC 封装层已集中环境变量、启动参数和通知路由常量
|
||||||
|
- [x] 架构文档已写明一期 `isolated-background`、二期 `isolated-window`
|
||||||
|
- [x] 架构文档已写明 `IPluginExportRegistry` / `IPluginMessageBus` 不再作为隔离插件主边界
|
||||||
|
- [x] 文档已写明 ClassIsland 的借鉴点与取舍
|
||||||
|
- [ ] Host 在 Worker 崩溃时仅降级插件且不中断主程序
|
||||||
|
- [ ] `isolated-background` 的组件、编辑器、设置页完成真实 IPC 回路
|
||||||
41
.trae/specs/plugin-process-isolation/spec.md
Normal file
41
.trae/specs/plugin-process-isolation/spec.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Plugin Process Isolation
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
现有插件体系仍是“同进程 + AssemblyLoadContext 隔离”,无法阻止插件 fatal crash 拖垮 Host,也无法阻止插件直接访问 Host 进程内对象和内存。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 增加插件运行模式概念:`in-proc`、`isolated-background`、`isolated-window`
|
||||||
|
- 一期落地 `isolated-background`
|
||||||
|
- 新建独立 IPC 契约包和 IPC 封装包
|
||||||
|
- 在 `PluginSdk` 中新增 Worker 入口与 `runtime.mode`
|
||||||
|
- 明确隔离模式下不再兼容对象实例共享型 API
|
||||||
|
- 新增正式架构文档说明 UI 方案、迁移策略、残余风险和 ClassIsland 借鉴
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `LanMountainDesktop.PluginSdk/`
|
||||||
|
- `LanMountainDesktop.PluginTemplate/`
|
||||||
|
- 新增 `LanMountainDesktop.PluginIsolation.Contracts/`
|
||||||
|
- 新增 `LanMountainDesktop.PluginIsolation.Ipc/`
|
||||||
|
- `docs/ARCHITECTURE.md`
|
||||||
|
- `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1
|
||||||
|
|
||||||
|
宿主必须同时支持存量 `in-proc` 插件与未来的隔离插件,不得以本次改造打断旧插件加载。
|
||||||
|
|
||||||
|
### Requirement 2
|
||||||
|
|
||||||
|
隔离插件的 Host/Worker 通信必须基于显式 IPC 路由和 DTO,而不是 Host 服务对象实例共享。
|
||||||
|
|
||||||
|
### Requirement 3
|
||||||
|
|
||||||
|
一期必须把后台逻辑隔离为独立 Worker 进程,并显式记录 Host UI 壳层的残余风险。
|
||||||
|
|
||||||
|
### Requirement 4
|
||||||
|
|
||||||
|
仓库文档必须把 ClassIsland IPC 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。
|
||||||
12
.trae/specs/plugin-process-isolation/tasks.md
Normal file
12
.trae/specs/plugin-process-isolation/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 梳理现有插件运行时、组件注册、设置页和共享对象边界
|
||||||
|
- [x] 形成插件进程隔离架构文档
|
||||||
|
- [x] 在 `.trae/specs/plugin-process-isolation/` 下补齐 spec、tasks、checklist
|
||||||
|
- [x] 在 `PluginSdk` 中增加 `runtime.mode`、Worker 入口接口和运行模式枚举
|
||||||
|
- [x] 新建 `LanMountainDesktop.PluginIsolation.Contracts`,沉淀纯 DTO、路由常量、错误码与 JSON context
|
||||||
|
- [x] 新建 `LanMountainDesktop.PluginIsolation.Ipc`,沉淀 ClassIsland 风格的 IPC 包装外壳
|
||||||
|
- [x] 更新插件模板 `plugin.json`,让新插件默认显式声明 `in-proc`
|
||||||
|
- [ ] 在 Host 侧接入真实 Worker 进程拉起与 dotnetCampus.Ipc 传输绑定
|
||||||
|
- [ ] 为 `isolated-background` 构建 Host UI 壳层适配器
|
||||||
|
- [ ] 为故障、心跳、降级与恢复补齐端到端测试
|
||||||
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Checklist (Deprecated)
|
||||||
|
|
||||||
|
- [x] Spec marked as deprecated.
|
||||||
|
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
|
||||||
|
- [x] No release workflow dependency remains on VeloPack.
|
||||||
15
.trae/specs/velopack-update-integration/spec.md
Normal file
15
.trae/specs/velopack-update-integration/spec.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# VeloPack Update Integration (Deprecated)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
|
||||||
|
|
||||||
|
## Deprecation Reason
|
||||||
|
|
||||||
|
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||||
|
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||||
|
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||||
|
|
||||||
|
## Migration Note
|
||||||
|
|
||||||
|
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.
|
||||||
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Tasks (Deprecated)
|
||||||
|
|
||||||
|
- [x] Mark VeloPack integration spec as deprecated.
|
||||||
|
- [x] Remove VeloPack runtime branches from launcher/host update path.
|
||||||
|
- [x] Remove VeloPack release workflow packaging steps.
|
||||||
|
- [ ] Keep archive for historical context only (no new implementation tasks here).
|
||||||
24
.trae/specs/window-slide-transition/checklist.md
Normal file
24
.trae/specs/window-slide-transition/checklist.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
* [x] AppSettingsSnapshot 包含 EnableSlideTransition 字段且默认为 false
|
||||||
|
|
||||||
|
* [x] DesktopPage 拥有名为 DesktopPageSlideTransform 的 TranslateTransform
|
||||||
|
|
||||||
|
* [x] DesktopPage.Transitions 包含 Opacity 和 TranslateTransform.X 两个 DoubleTransition
|
||||||
|
|
||||||
|
* [x] 点击"回到 Windows"时播放退场动画(Opacity 淡出 或 Opacity+滑动),动画完成后再最小化
|
||||||
|
|
||||||
|
* [x] 从最小化恢复时 DesktopPage 先以 Opacity=0 遮住 Normal 中间态,FullScreen 生效后播放入场动画
|
||||||
|
|
||||||
|
* [x] 动画期间 DesktopPage.IsHitTestVisible 为 false,动画完成后恢复
|
||||||
|
|
||||||
|
* [x] 动画期间 OnWindowPropertyChanged 不执行强制全屏纠正
|
||||||
|
|
||||||
|
* [x] 快速连续操作不会导致动画冲突
|
||||||
|
|
||||||
|
* [x] GeneralSettingsPage 在 Windows 平台显示"滑入滑出过渡效果"开关
|
||||||
|
|
||||||
|
* [x] GeneralSettingsPage 在非 Windows 平台不显示该开关
|
||||||
|
|
||||||
|
* [x] EnableSlideTransition 设置持久化到 AppSettingsSnapshot 且立即生效
|
||||||
|
|
||||||
|
* [x] dotnet build 无编译错误
|
||||||
|
|
||||||
138
.trae/specs/window-slide-transition/spec.md
Normal file
138
.trae/specs/window-slide-transition/spec.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 窗口过渡动画 Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前全屏窗口在"回到 Windows"(最小化)和"恢复应用"时存在严重的视觉问题:
|
||||||
|
1. 恢复时经历 `Minimized → Normal → FullScreen` 两步跳变,用户会短暂看到无框小窗口
|
||||||
|
2. 状态切换无任何过渡动画,体验生硬
|
||||||
|
3. `OnWindowPropertyChanged` 使用 `Dispatcher.UIThread.Post` 延迟纠正,进一步延长了 Normal 中间态的可见时间
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 在 `MainWindow.axaml` 的 `DesktopPage` 上添加 `TranslateTransform` 和 `TranslateTransform.X` 过渡动画
|
||||||
|
- 修改 `MainWindow.axaml.cs` 的 `OnMinimizeClick`,实现退场动画(滑出/淡出 → 最小化)
|
||||||
|
- 修改 `App.axaml.cs` 的 `RestoreOrCreateMainWindow`,实现入场动画(全屏 → 滑入/淡入)
|
||||||
|
- 修改 `MainWindow.axaml.cs` 的 `OnWindowPropertyChanged`,在动画期间暂停强制全屏逻辑
|
||||||
|
- 在 `AppSettingsSnapshot` 中添加 `EnableSlideTransition` 设置项(默认关闭)
|
||||||
|
- 在 `GeneralSettingsPageViewModel` 中添加对应 ViewModel 属性
|
||||||
|
- 在 `GeneralSettingsPage.axaml` 中添加开关 UI(仅 Windows 平台显示)
|
||||||
|
- 添加平台检测逻辑:Windows 且开启设置时使用滑入滑出,其他情况使用 Opacity 淡入淡出
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected specs: 窗口生命周期过渡动画
|
||||||
|
- Affected code:
|
||||||
|
- `LanMountainDesktop/Views/MainWindow.axaml` - DesktopPage 添加 TranslateTransform
|
||||||
|
- `LanMountainDesktop/Views/MainWindow.axaml.cs` - OnMinimizeClick、OnWindowPropertyChanged、新增动画方法
|
||||||
|
- `LanMountainDesktop/App.axaml.cs` - RestoreOrCreateMainWindow、OnMainWindowPropertyChanged
|
||||||
|
- `LanMountainDesktop/Models/AppSettingsSnapshot.cs` - 新增 EnableSlideTransition 字段
|
||||||
|
- `LanMountainDesktop/ViewModels/SettingsViewModels.cs` - GeneralSettingsPageViewModel 新增属性
|
||||||
|
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml` - 新增开关 UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 窗口退场过渡动画
|
||||||
|
|
||||||
|
系统 SHALL 在主窗口最小化/隐藏时播放退场过渡动画,消除窗口状态跳变的视觉闪烁。
|
||||||
|
|
||||||
|
#### Scenario: Opacity 淡出退场(所有平台默认)
|
||||||
|
- **WHEN** 用户点击"回到 Windows"或触发最小化
|
||||||
|
- **THEN** 系统将 `DesktopPage.Opacity` 设为 0,触发淡出动画
|
||||||
|
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
|
||||||
|
- **AND THEN** 最小化完成后重置 `DesktopPage.Opacity = 1`(窗口已不可见)
|
||||||
|
|
||||||
|
#### Scenario: 滑出退场(Windows + 开启设置)
|
||||||
|
- **WHEN** 用户点击"回到 Windows"且运行在 Windows 平台且已开启滑入滑出设置
|
||||||
|
- **THEN** 系统同时将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
|
||||||
|
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
|
||||||
|
- **AND THEN** 最小化完成后重置 `DesktopPageSlideTransform.X = 0` 和 `DesktopPage.Opacity = 1`
|
||||||
|
|
||||||
|
### Requirement: 窗口入场过渡动画
|
||||||
|
|
||||||
|
系统 SHALL 在主窗口恢复时播放入场过渡动画,消除 Normal 中间态的视觉闪烁。
|
||||||
|
|
||||||
|
#### Scenario: Opacity 淡入入场(所有平台默认)
|
||||||
|
- **WHEN** 主窗口从最小化/隐藏状态恢复
|
||||||
|
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0(遮住 Normal 中间态)
|
||||||
|
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
|
||||||
|
- **AND THEN** 等 FullScreen 状态生效后将 `DesktopPage.Opacity` 设为 1,触发淡入动画
|
||||||
|
|
||||||
|
#### Scenario: 滑入入场(Windows + 开启设置)
|
||||||
|
- **WHEN** 主窗口从最小化/隐藏状态恢复且运行在 Windows 平台且已开启滑入滑出设置
|
||||||
|
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
|
||||||
|
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
|
||||||
|
- **AND THEN** 等 FullScreen 状态生效后同时将 `DesktopPage.Opacity` 设为 1 且 `DesktopPageSlideTransform.X` 设为 0,触发滑入+淡入组合动画
|
||||||
|
|
||||||
|
### Requirement: 动画期间交互保护
|
||||||
|
|
||||||
|
系统 SHALL 在过渡动画播放期间防止用户交互和状态冲突。
|
||||||
|
|
||||||
|
#### Scenario: 动画期间禁止交互
|
||||||
|
- **WHEN** 退场或入场动画正在播放
|
||||||
|
- **THEN** `DesktopPage.IsHitTestVisible` 设为 `false`
|
||||||
|
- **AND THEN** 动画完成后恢复为 `true`
|
||||||
|
|
||||||
|
#### Scenario: 动画期间暂停强制全屏
|
||||||
|
- **WHEN** 入场动画正在播放且窗口临时处于 Normal 状态
|
||||||
|
- **THEN** `OnWindowPropertyChanged` 不执行强制全屏纠正
|
||||||
|
- **AND THEN** 入场动画完成后恢复正常强制全屏逻辑
|
||||||
|
|
||||||
|
#### Scenario: 防止快速连续操作
|
||||||
|
- **WHEN** 用户在动画播放期间再次触发最小化或恢复
|
||||||
|
- **THEN** 系统忽略重复操作,避免动画冲突
|
||||||
|
|
||||||
|
### Requirement: 滑入滑出设置项
|
||||||
|
|
||||||
|
系统 SHALL 在基本设置页面提供"滑入滑出过渡效果"开关,仅 Windows 平台可见。
|
||||||
|
|
||||||
|
#### Scenario: 设置项可见性
|
||||||
|
- **WHEN** 用户在 Windows 平台打开基本设置页面
|
||||||
|
- **THEN** 显示"滑入滑出过渡效果"开关
|
||||||
|
- **WHEN** 用户在非 Windows 平台打开基本设置页面
|
||||||
|
- **THEN** 不显示该开关
|
||||||
|
|
||||||
|
#### Scenario: 设置项默认值
|
||||||
|
- **WHEN** 用户首次安装应用
|
||||||
|
- **THEN** `EnableSlideTransition` 默认为 `false`
|
||||||
|
|
||||||
|
#### Scenario: 设置持久化
|
||||||
|
- **WHEN** 用户切换"滑入滑出过渡效果"开关
|
||||||
|
- **THEN** 设置值立即持久化到 `AppSettingsSnapshot.EnableSlideTransition`
|
||||||
|
- **AND THEN** 下次窗口过渡时立即生效,无需重启
|
||||||
|
|
||||||
|
### Requirement: DesktopPage TranslateTransform 声明
|
||||||
|
|
||||||
|
系统 SHALL 在 `DesktopPage` 上声明 `TranslateTransform` 和对应的过渡动画。
|
||||||
|
|
||||||
|
#### Scenario: XAML 声明
|
||||||
|
- **WHEN** MainWindow 初始化
|
||||||
|
- **THEN** `DesktopPage` 拥有名为 `DesktopPageSlideTransform` 的 `TranslateTransform`
|
||||||
|
- **AND THEN** `DesktopPage.Transitions` 包含 `Opacity` 和 `TranslateTransform.X` 两个过渡
|
||||||
|
- **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`(320ms)和 `FluttermotionToken.Duration.Intro`(400ms)
|
||||||
|
- **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`(DecelerateBezier)
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: OnMinimizeClick 行为
|
||||||
|
|
||||||
|
**当前**: 直接设置 `WindowState = WindowState.Minimized`,无动画
|
||||||
|
|
||||||
|
**修改后**: 先播放退场动画,动画完成后再设置 `WindowState = WindowState.Minimized`
|
||||||
|
|
||||||
|
### Requirement: RestoreOrCreateMainWindow 行为
|
||||||
|
|
||||||
|
**当前**: `Show() → Normal → FullScreen`,无过渡动画,用户可见 Normal 中间态
|
||||||
|
|
||||||
|
**修改后**: 先将 `DesktopPage` 设为不可见(Opacity=0 + 可选滑出位),再执行状态切换,最后播放入场动画
|
||||||
|
|
||||||
|
### Requirement: OnWindowPropertyChanged 强制全屏逻辑
|
||||||
|
|
||||||
|
**当前**: 任何非 Minimized/FullScreen 状态立即纠正为 FullScreen
|
||||||
|
|
||||||
|
**修改后**: 动画期间允许临时 Normal 状态存在,动画完成后恢复强制全屏逻辑
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
无移除的需求。
|
||||||
52
.trae/specs/window-slide-transition/tasks.md
Normal file
52
.trae/specs/window-slide-transition/tasks.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Task 1: 在 `AppSettingsSnapshot` 中添加 `EnableSlideTransition` 字段
|
||||||
|
- [x] 添加 `public bool EnableSlideTransition { get; set; } = false;`
|
||||||
|
- [x] 在 `Clone()` 方法中无需特殊处理(bool 是值类型)
|
||||||
|
|
||||||
|
- [x] Task 2: 在 `MainWindow.axaml` 的 `DesktopPage` 上添加 `TranslateTransform` 和过渡动画
|
||||||
|
- [x] 添加 `<TranslateTransform />`
|
||||||
|
- [x] 在 `Grid.Transitions` 中添加 `TranslateTransform.X` 的 `DoubleTransition`,使用 `FluttermotionToken.Duration.Intro` 和 DecelerateBezier 缓动
|
||||||
|
|
||||||
|
- [x] Task 3: 在 `MainWindow.axaml.cs` 中实现退场动画逻辑
|
||||||
|
- [x] 添加 `_isSlideAnimationActive` 标志位
|
||||||
|
- [x] 修改 `OnMinimizeClick`,调用新的 `SlideOutAndMinimizeAsync` 方法
|
||||||
|
- [x] 实现 `SlideOutAndMinimizeAsync`:读取设置 → 播放退场动画(Opacity + 可选滑动)→ 等动画完成 → 最小化 → 重置位置
|
||||||
|
- [x] 动画期间设置 `DesktopPage.IsHitTestVisible = false`
|
||||||
|
|
||||||
|
- [x] Task 4: 在 `MainWindow.axaml.cs` 中实现入场动画逻辑
|
||||||
|
- [x] 添加 `public void PrepareEnterAnimation()` 方法:禁用过渡 → 设置初始位置(Opacity=0, X=屏幕宽度或0)→ 重新启用过渡
|
||||||
|
- [x] 添加 `public void PlayEnterAnimation()` 方法:触发入场动画(Opacity=1, X=0)
|
||||||
|
- [x] 添加 `private bool IsSlideTransitionEnabled()` 方法,从设置中读取
|
||||||
|
|
||||||
|
- [x] Task 5: 修改 `App.axaml.cs` 的 `RestoreOrCreateMainWindow`
|
||||||
|
- [x] 在窗口状态切换前调用 `mainWindow.PrepareEnterAnimation()`
|
||||||
|
- [x] 在 FullScreen 状态生效后调用 `mainWindow.PlayEnterAnimation()`
|
||||||
|
|
||||||
|
- [x] Task 6: 修改 `MainWindow.axaml.cs` 的 `OnWindowPropertyChanged`
|
||||||
|
- [x] 当 `_isSlideAnimationActive` 为 true 时跳过强制全屏逻辑
|
||||||
|
|
||||||
|
- [x] Task 7: 在 `GeneralSettingsPageViewModel` 中添加 `EnableSlideTransition` 属性
|
||||||
|
- [x] 添加 `[ObservableProperty] private bool _enableSlideTransition;`
|
||||||
|
- [x] 添加 `OnEnableSlideTransitionChanged` 持久化方法
|
||||||
|
- [x] 在构造函数和 `OnSettingsChanged` 中加载/同步该设置
|
||||||
|
- [x] 添加 `IsSlideTransitionAvailable` 平台检测属性
|
||||||
|
|
||||||
|
- [x] Task 8: 在 `GeneralSettingsPage.axaml` 中添加"滑入滑出过渡效果"开关
|
||||||
|
- [x] 在"运行时设置"分组中添加 `SettingsExpander`
|
||||||
|
- [x] 仅 Windows 平台显示(使用 `IsVisible` 绑定到 `IsSlideTransitionAvailable`)
|
||||||
|
- [x] 图标使用 `ArrowRight`
|
||||||
|
|
||||||
|
- [x] Task 9: 构建验证
|
||||||
|
- [x] 执行 `dotnet build` 确保无编译错误
|
||||||
|
|
||||||
|
# Task Dependencies
|
||||||
|
|
||||||
|
- [Task 2] depends on [Task 1]
|
||||||
|
- [Task 3] depends on [Task 1, Task 2]
|
||||||
|
- [Task 4] depends on [Task 1, Task 2]
|
||||||
|
- [Task 5] depends on [Task 4]
|
||||||
|
- [Task 6] depends on [Task 3]
|
||||||
|
- [Task 7] depends on [Task 1]
|
||||||
|
- [Task 8] depends on [Task 7]
|
||||||
|
- [Task 9] depends on [Task 3, Task 4, Task 5, Task 6, Task 7, Task 8]
|
||||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,6 +1,31 @@
|
|||||||
# 更新日志 / Changelog
|
# 更新日志 / Changelog
|
||||||
|
|
||||||
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
|
## [0.8.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.4) - 2026-04-12
|
||||||
|
|
||||||
|
### 新增 (Added)
|
||||||
|
|
||||||
|
- ✨ **全新淡入淡出动画系统**: 引入了一套全新的淡入淡出动画效果
|
||||||
|
- 提升界面切换和元素显示的视觉流畅度
|
||||||
|
- 为用户带来更加自然优雅的交互体验
|
||||||
|
|
||||||
|
### 变更 (Changed)
|
||||||
|
|
||||||
|
- ♻️ **SDK 更新**: 更新插件 SDK,优化插件开发接口和兼容性
|
||||||
|
- 🎨 **网速显示组件优化**: 优化了网速显示组件的显示效果
|
||||||
|
- 改进数据展示方式,提升可读性
|
||||||
|
- 优化视觉样式,与整体设计语言更加协调
|
||||||
|
|
||||||
|
### 修复 (Fixed)
|
||||||
|
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 移除 (Removed)
|
||||||
|
|
||||||
|
- 无
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## [0.8.3.5](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.5) - 2026-04-12
|
||||||
|
|
||||||
### 新增 (Added)
|
### 新增 (Added)
|
||||||
|
|
||||||
@@ -12,11 +37,46 @@
|
|||||||
- 插件开发者可以通过 View 自定义设置页面的 UI 和交互
|
- 插件开发者可以通过 View 自定义设置页面的 UI 和交互
|
||||||
- 提供更灵活的设置页面展示方式,提升插件用户体验
|
- 提供更灵活的设置页面展示方式,提升插件用户体验
|
||||||
- 兼容原有的设置方式,平滑过渡
|
- 兼容原有的设置方式,平滑过渡
|
||||||
|
- 🔧 **三指滑动与融合桌面功能开关位置调整**: 将三指滑动与融合桌面功能开关移动到了开发者设置界面
|
||||||
|
- 优化设置页面结构,将高级功能集中管理
|
||||||
|
- 普通用户界面更加简洁,开发者可在已有的开发者设置界面中访问相关设置
|
||||||
|
|
||||||
### 修复 (Fixed)
|
### 修复 (Fixed)
|
||||||
|
|
||||||
|
- 🐛 **快捷方式组件透明问题**: 修复了快捷方式组件无法正常透明的问题
|
||||||
|
- 问题原因: 组件背景透明属性设置异常或渲染层级问题
|
||||||
|
- 修复方案: 修正透明属性配置,确保快捷方式组件背景透明效果正常显示
|
||||||
|
- 🐛 **插件无法正常升级问题**: 修复了插件无法正常升级的问题
|
||||||
|
- 问题原因: 插件升级流程中存在异常,导致升级操作失败或中断
|
||||||
|
- 修复方案: 修复插件升级逻辑,确保插件可以正常检测、下载和安装更新
|
||||||
|
- 🐛 **开发者设置项持久化问题**: 修复了开发者设置项不能正确持久化的问题
|
||||||
|
- 问题原因: 开发者设置项的保存或读取逻辑存在缺陷,导致设置无法正确保存或恢复
|
||||||
|
- 修复方案: 修复设置持久化逻辑,确保开发者设置项能够正确保存并在重启后恢复
|
||||||
|
|
||||||
|
### 移除 (Removed)
|
||||||
|
|
||||||
|
- 🗑️ **不附带 .NET 10 依赖的轻量版安装包**: 移除了不附带 .NET 10 依赖的轻量版安装包
|
||||||
|
- 简化版本发布和维护流程,统一提供完整依赖的安装包
|
||||||
|
- 用户无需担心 .NET 运行时环境,安装后即可直接使用
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
|
||||||
|
|
||||||
|
### 新增 (Added)
|
||||||
|
|
||||||
- 无
|
- 无
|
||||||
|
|
||||||
|
### 变更 (Changed)
|
||||||
|
|
||||||
|
- ♻️ **插件 SDK 更新**: 更新插件 SDK,优化插件开发接口和兼容性
|
||||||
|
|
||||||
|
### 修复 (Fixed)
|
||||||
|
|
||||||
|
- 🐛 **轻量版 .NET 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 环境下的依赖问题
|
||||||
|
- 问题原因: 轻量版与 .NET 的依赖兼容性存在冲突
|
||||||
|
- 修复方案: 调整依赖配置,提升兼容性(实验性修复,持续观察中)
|
||||||
|
|
||||||
### 移除 (Removed)
|
### 移除 (Removed)
|
||||||
|
|
||||||
- 无
|
- 无
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
|
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
|
||||||
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
|
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
|
||||||
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
|
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
|
||||||
|
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
9
LanMountainDesktop.Launcher/App.axaml
Normal file
9
LanMountainDesktop.Launcher/App.axaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:sty="using:FluentAvalonia.Styling"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<Application.Styles>
|
||||||
|
<sty:FluentAvaloniaTheme />
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
342
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
342
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
Logger.Initialize();
|
||||||
|
var context = LauncherRuntimeContext.Current;
|
||||||
|
var execution = LauncherExecutionContext.Capture();
|
||||||
|
Logger.Info(
|
||||||
|
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
|
||||||
|
$"IsPreview={context.IsPreviewCommand}; IsDebugMode={context.IsDebugMode}; " +
|
||||||
|
$"LaunchSource='{context.LaunchSource}'; IsElevated={execution.IsElevated}; " +
|
||||||
|
$"UserSid='{execution.UserSid ?? string.Empty}'; ExplicitAppRoot='{context.ExplicitAppRoot ?? "<none>"}'.");
|
||||||
|
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
|
var context = LauncherRuntimeContext.Current;
|
||||||
|
var execution = LauncherExecutionContext.Capture();
|
||||||
|
Logger.Info(
|
||||||
|
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
|
||||||
|
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||||
|
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
|
||||||
|
|
||||||
|
if (HandlePreviewCommand(context, desktop))
|
||||||
|
{
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var updateWindow = new UpdateWindow();
|
||||||
|
updateWindow.Show();
|
||||||
|
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var splashWindow = new SplashWindow();
|
||||||
|
splashWindow.Show();
|
||||||
|
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
switch (context.Command.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "preview-splash":
|
||||||
|
{
|
||||||
|
Logger.Info("Preview command: splash.");
|
||||||
|
var splashWindow = new SplashWindow();
|
||||||
|
splashWindow.SetDebugMode(true);
|
||||||
|
splashWindow.Show();
|
||||||
|
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "preview-error":
|
||||||
|
{
|
||||||
|
Logger.Info("Preview command: error.");
|
||||||
|
var errorWindow = new ErrorWindow();
|
||||||
|
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
|
||||||
|
errorWindow.Show();
|
||||||
|
_ = WaitForWindowCloseAsync(desktop, errorWindow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "preview-update":
|
||||||
|
{
|
||||||
|
Logger.Info("Preview command: update.");
|
||||||
|
var updateWindow = new UpdateWindow();
|
||||||
|
updateWindow.SetDebugMode(true);
|
||||||
|
updateWindow.Show();
|
||||||
|
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "preview-oobe":
|
||||||
|
{
|
||||||
|
Logger.Info("Preview command: oobe.");
|
||||||
|
var oobeWindow = new OobeWindow();
|
||||||
|
oobeWindow.Show();
|
||||||
|
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "preview-debug":
|
||||||
|
{
|
||||||
|
Logger.Info("Preview command: debug window.");
|
||||||
|
var devDebugWindow = new DevDebugWindow();
|
||||||
|
devDebugWindow.Show();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||||
|
{
|
||||||
|
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
||||||
|
var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" };
|
||||||
|
var reporter = (ISplashStageReporter)window;
|
||||||
|
|
||||||
|
for (var i = 0; i < stages.Length; i++)
|
||||||
|
{
|
||||||
|
reporter.Report(stages[i], messages[i]);
|
||||||
|
await Task.Delay(800).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(5000).ConfigureAwait(false);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
|
||||||
|
{
|
||||||
|
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
|
||||||
|
|
||||||
|
for (var i = 0; i < stages.Length; i++)
|
||||||
|
{
|
||||||
|
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
|
||||||
|
await Task.Delay(600).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ReportComplete(true, null);
|
||||||
|
await Task.Delay(3000).ConfigureAwait(false);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||||
|
Logger.Info("OOBE preview completed by user.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("OOBE preview failed.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
window.Closed += (_, _) => tcs.TrySetResult();
|
||||||
|
await tcs.Task.ConfigureAwait(false);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunCoordinatorWithSplashAsync(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
CommandContext context,
|
||||||
|
SplashWindow splashWindow)
|
||||||
|
{
|
||||||
|
LauncherResult result;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appRoot = Commands.ResolveAppRoot(context);
|
||||||
|
Logger.Info(
|
||||||
|
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||||
|
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||||
|
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||||
|
|
||||||
|
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
|
var coordinator = new LauncherFlowCoordinator(
|
||||||
|
context,
|
||||||
|
deploymentLocator,
|
||||||
|
new OobeStateService(appRoot),
|
||||||
|
new UpdateEngineService(deploymentLocator),
|
||||||
|
new PluginInstallerService());
|
||||||
|
|
||||||
|
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||||
|
result = new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "launch",
|
||||||
|
Code = "exception",
|
||||||
|
Message = $"Launcher failed: {ex.Message}",
|
||||||
|
ErrorMessage = ex.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||||
|
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!result.Success &&
|
||||||
|
result.Code is not "host_not_found" &&
|
||||||
|
(string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Environment.ExitCode = result.Success ? 0 : 1;
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||||
|
{
|
||||||
|
var resultPath = context.GetOption("result");
|
||||||
|
if (string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||||
|
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ShowFailureWindowAsync(LauncherResult result)
|
||||||
|
{
|
||||||
|
ErrorWindow? errorWindow = null;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
errorWindow = new ErrorWindow();
|
||||||
|
errorWindow.SetErrorMessage(
|
||||||
|
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
||||||
|
errorWindow.Show();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to show launcher failure window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errorWindow is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunApplyUpdateWithWindowAsync(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
CommandContext context,
|
||||||
|
UpdateWindow window)
|
||||||
|
{
|
||||||
|
var appRoot = Commands.ResolveAppRoot(context);
|
||||||
|
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
|
var updateEngine = new UpdateEngineService(deploymentLocator);
|
||||||
|
var pluginInstaller = new PluginInstallerService();
|
||||||
|
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||||
|
|
||||||
|
var success = true;
|
||||||
|
string? errorMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
|
||||||
|
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||||
|
if (!updateResult.Success)
|
||||||
|
{
|
||||||
|
success = false;
|
||||||
|
errorMessage = updateResult.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
|
||||||
|
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
|
||||||
|
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||||
|
if (!queueResult.Success && queueResult.Code != "noop")
|
||||||
|
{
|
||||||
|
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
|
||||||
|
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
success = false;
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
Logger.Error("Apply-update flow failed.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
|
||||||
|
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
|
||||||
|
{
|
||||||
|
Success = success,
|
||||||
|
Stage = "apply-update",
|
||||||
|
Code = success ? "ok" : "failed",
|
||||||
|
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
|
||||||
|
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["command"] = context.Command,
|
||||||
|
["launchSource"] = context.LaunchSource
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Environment.ExitCode = success ? 0 : 1;
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
32
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true)]
|
||||||
|
[JsonSerializable(typeof(SignedFileMap))]
|
||||||
|
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||||
|
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
||||||
|
[JsonSerializable(typeof(PlondsFileMap))]
|
||||||
|
[JsonSerializable(typeof(PlondsComponentEntry))]
|
||||||
|
[JsonSerializable(typeof(PlondsFileEntry))]
|
||||||
|
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
||||||
|
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||||
|
[JsonSerializable(typeof(AppVersionInfo))]
|
||||||
|
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||||
|
[JsonSerializable(typeof(LauncherResult))]
|
||||||
|
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
||||||
|
[JsonSerializable(typeof(PluginManifest))]
|
||||||
|
[JsonSerializable(typeof(PendingUpgrade))]
|
||||||
|
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||||
|
[JsonSerializable(typeof(OobeStateFile))]
|
||||||
|
[JsonSerializable(typeof(GitHubRelease))]
|
||||||
|
[JsonSerializable(typeof(GitHubAsset))]
|
||||||
|
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||||
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
BIN
LanMountainDesktop.Launcher/Assets/logo.ico
Normal file
BIN
LanMountainDesktop.Launcher/Assets/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
11
LanMountainDesktop.Launcher/Assets/public-key.pem
Normal file
11
LanMountainDesktop.Launcher/Assets/public-key.pem
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-----BEGIN RSA PUBLIC KEY-----
|
||||||
|
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
|
||||||
|
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
|
||||||
|
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
|
||||||
|
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
|
||||||
|
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
|
||||||
|
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
|
||||||
|
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
|
||||||
|
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
|
||||||
|
FQiGowgqx0l5AgMBAAE=
|
||||||
|
-----END RSA PUBLIC KEY-----
|
||||||
160
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
160
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
|
internal sealed class CommandContext
|
||||||
|
{
|
||||||
|
private const string LaunchSourceOptionName = "launch-source";
|
||||||
|
|
||||||
|
private static readonly string[] GuiCommands =
|
||||||
|
[
|
||||||
|
"launch",
|
||||||
|
"apply-update",
|
||||||
|
"preview-splash",
|
||||||
|
"preview-error",
|
||||||
|
"preview-update",
|
||||||
|
"preview-oobe",
|
||||||
|
"preview-debug"
|
||||||
|
];
|
||||||
|
|
||||||
|
public string Command { get; }
|
||||||
|
|
||||||
|
public string SubCommand { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> Options { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 原始命令行参数,用于转发给主程序
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> RawArgs { get; }
|
||||||
|
|
||||||
|
public bool IsLegacyPluginInstall =>
|
||||||
|
Options.ContainsKey("source") &&
|
||||||
|
Options.ContainsKey("plugins-dir") &&
|
||||||
|
Options.ContainsKey("result");
|
||||||
|
|
||||||
|
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||||
|
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDebugMode =>
|
||||||
|
Options.ContainsKey("debug") ||
|
||||||
|
System.Diagnostics.Debugger.IsAttached;
|
||||||
|
|
||||||
|
public bool IsPreviewCommand =>
|
||||||
|
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public bool IsGuiCommand =>
|
||||||
|
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public bool IsMaintenanceCommand =>
|
||||||
|
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public string? ExplicitAppRoot => GetOption("app-root");
|
||||||
|
|
||||||
|
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
|
||||||
|
{
|
||||||
|
Command = command;
|
||||||
|
SubCommand = subCommand;
|
||||||
|
Options = options;
|
||||||
|
RawArgs = rawArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CommandContext FromArgs(string[] args)
|
||||||
|
{
|
||||||
|
var options = ParseOptions(args);
|
||||||
|
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
|
||||||
|
? args[0]
|
||||||
|
: "launch";
|
||||||
|
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
|
||||||
|
? args[1]
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
return new CommandContext(command, subCommand, options, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetOption(string key)
|
||||||
|
{
|
||||||
|
return Options.TryGetValue(key, out var value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetIntOption(string key, int fallback)
|
||||||
|
{
|
||||||
|
var raw = GetOption(key);
|
||||||
|
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||||
|
? value
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string InferLaunchSource()
|
||||||
|
{
|
||||||
|
if (IsPreviewCommand)
|
||||||
|
{
|
||||||
|
return "debug-preview";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "apply-update";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "plugin-install";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeLaunchSource(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"normal" => "normal",
|
||||||
|
"postinstall" => "postinstall",
|
||||||
|
"apply-update" => "apply-update",
|
||||||
|
"plugin-install" => "plugin-install",
|
||||||
|
"debug-preview" => "debug-preview",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var current = args[i];
|
||||||
|
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = current[2..];
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
values[key] = args[++i];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[key] = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!-- AOT 发布配置文件 -->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
|
<!-- 启用 Native AOT -->
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
|
||||||
|
<!-- 启用修剪以减小体积 -->
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<TrimMode>partial</TrimMode>
|
||||||
|
|
||||||
|
<!-- 自包含(不依赖系统 .NET Runtime) -->
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
|
||||||
|
<!-- 单文件发布 -->
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|
||||||
|
<!-- 包含 native 库到单文件中 -->
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
|
||||||
|
<!-- 压缩单文件 -->
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
|
||||||
|
<!-- 优化大小 -->
|
||||||
|
<OptimizationPreference>Size</OptimizationPreference>
|
||||||
|
|
||||||
|
<!-- 禁用 ReadyToRun(AOT 不需要) -->
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
|
||||||
|
<!-- 注意:RuntimeIdentifier 由 CI/CD 工作流通过 -r 参数传入,不在此处硬编码 -->
|
||||||
|
<!-- 支持的平台:win-x64, win-x86, linux-x64, osx-x64, osx-arm64 -->
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- AOT 兼容性设置 -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- 允许不安全代码(某些 AOT 场景需要) -->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
|
||||||
|
<!-- 启用编译时绑定(Avalonia 需要) -->
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- AOT 修剪配置 -->
|
||||||
|
<ItemGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
|
<!-- 保留 Avalonia 必要的类型 -->
|
||||||
|
<TrimmerRootAssembly Include="Avalonia" />
|
||||||
|
<TrimmerRootAssembly Include="Avalonia.Desktop" />
|
||||||
|
|
||||||
|
<!-- 保留动态序列化类型 -->
|
||||||
|
<TrimmerRootAssembly Include="System.Text.Json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
||||||
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
|
<!-- 忽略某些警告 -->
|
||||||
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
|
<!-- 允许 IL 警告 -->
|
||||||
|
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||||
|
|
||||||
|
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||||
|
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
|
||||||
|
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||||
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!-- 单文件发布配置文件(非 AOT,但接近单文件体验) -->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup Condition="'$(PublishSingleFileMode)' == 'true'">
|
||||||
|
<!-- 自包含 -->
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
|
||||||
|
<!-- 单文件发布 -->
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|
||||||
|
<!-- 包含 native 库到单文件中 -->
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
|
||||||
|
<!-- 压缩单文件 -->
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
|
||||||
|
<!-- ReadyToRun 预编译(提升启动速度) -->
|
||||||
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
|
|
||||||
|
<!-- 修剪以减小体积 -->
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<TrimMode>partial</TrimMode>
|
||||||
|
|
||||||
|
<!-- 优化大小 -->
|
||||||
|
<OptimizationPreference>Size</OptimizationPreference>
|
||||||
|
|
||||||
|
<!-- 目标运行时 -->
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||||
|
|
||||||
|
<!-- 导入 AOT 配置 -->
|
||||||
|
<Import Project="LanMountainDesktop.Launcher.AOT.props" Condition="Exists('LanMountainDesktop.Launcher.AOT.props')" />
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<PackageVersion>$(Version)</PackageVersion>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<!-- 应用程序图标 -->
|
||||||
|
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||||
|
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||||
|
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- 资源文件 -->
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 公钥文件 -->
|
||||||
|
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<!-- Avalonia 资源文件 -->
|
||||||
|
<AvaloniaResource Include="Assets\logo.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
||||||
|
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
<MakeDir Directories="$(PublicKeyDestDir)" />
|
||||||
|
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
||||||
|
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
||||||
|
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
|
internal static class LauncherRuntimeContext
|
||||||
|
{
|
||||||
|
public static CommandContext Current { get; set; } = CommandContext.FromArgs([]);
|
||||||
|
}
|
||||||
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
internal sealed class LauncherResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("success")]
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("stage")]
|
||||||
|
public string Stage { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public string Code { get; init; } = "ok";
|
||||||
|
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("currentVersion")]
|
||||||
|
public string? CurrentVersion { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("targetVersion")]
|
||||||
|
public string? TargetVersion { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rolledBackTo")]
|
||||||
|
public string? RolledBackTo { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("details")]
|
||||||
|
public Dictionary<string, string> Details { get; init; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("installedPackagePath")]
|
||||||
|
public string? InstalledPackagePath { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("manifestId")]
|
||||||
|
public string? ManifestId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("manifestName")]
|
||||||
|
public string? ManifestName { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("errorMessage")]
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
internal enum OobeStateStatus
|
||||||
|
{
|
||||||
|
FirstRun,
|
||||||
|
Completed,
|
||||||
|
Unavailable,
|
||||||
|
Suppressed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OobeStateFile
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; init; } = 1;
|
||||||
|
|
||||||
|
public string CompletedAtUtc { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string UserName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? UserSid { get; init; }
|
||||||
|
|
||||||
|
public string LaunchSource { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OobeLaunchDecision
|
||||||
|
{
|
||||||
|
public OobeStateStatus Status { get; init; }
|
||||||
|
|
||||||
|
public bool ShouldShowOobe { get; init; }
|
||||||
|
|
||||||
|
public string StatePath { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string LaunchSource { get; init; } = "normal";
|
||||||
|
|
||||||
|
public bool IsElevated { get; init; }
|
||||||
|
|
||||||
|
public string UserName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? UserSid { get; init; }
|
||||||
|
|
||||||
|
public string ResultCode { get; init; } = "ok";
|
||||||
|
|
||||||
|
public string SuppressionReason { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ErrorMessage { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool UsedLegacyMarker { get; init; }
|
||||||
|
|
||||||
|
public bool MigratedLegacyMarker { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OobeCompletionResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string ResultCode { get; init; } = "ok";
|
||||||
|
|
||||||
|
public string ErrorMessage { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record LauncherExecutionSnapshot(
|
||||||
|
bool IsElevated,
|
||||||
|
string UserName,
|
||||||
|
string? UserSid);
|
||||||
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GitHub Release 信息
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReleaseInfo
|
||||||
|
{
|
||||||
|
public required string TagName { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required bool Prerelease { get; init; }
|
||||||
|
public required DateTime PublishedAt { get; init; }
|
||||||
|
public required List<ReleaseAsset> Assets { get; init; }
|
||||||
|
public string? Body { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Release 资源文件
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReleaseAsset
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string BrowserDownloadUrl { get; init; }
|
||||||
|
public required long Size { get; init; }
|
||||||
|
}
|
||||||
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新频道
|
||||||
|
/// </summary>
|
||||||
|
public enum UpdateChannel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 正式版 - 只检查 prerelease=false 的版本
|
||||||
|
/// </summary>
|
||||||
|
Stable,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
||||||
|
/// </summary>
|
||||||
|
Preview
|
||||||
|
}
|
||||||
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
144
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
144
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
internal sealed class SignedFileMap
|
||||||
|
{
|
||||||
|
public string? FromVersion { get; set; }
|
||||||
|
|
||||||
|
public string? ToVersion { get; set; }
|
||||||
|
|
||||||
|
public string? Platform { get; set; }
|
||||||
|
|
||||||
|
public string? Arch { get; set; }
|
||||||
|
|
||||||
|
public List<UpdateFileEntry> Files { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UpdateFileEntry
|
||||||
|
{
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? ArchivePath { get; set; }
|
||||||
|
|
||||||
|
public string Action { get; set; } = "replace";
|
||||||
|
|
||||||
|
public string? Sha256 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotMetadata
|
||||||
|
{
|
||||||
|
public string SnapshotId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SourceVersion { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? TargetVersion { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public string SourceDirectory { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? TargetDirectory { get; set; }
|
||||||
|
|
||||||
|
public string Status { get; set; } = "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
76
LanMountainDesktop.Launcher/Program.cs
Normal file
76
LanMountainDesktop.Launcher/Program.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
private static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
var commandContext = CommandContext.FromArgs(args);
|
||||||
|
var execution = LauncherExecutionContext.Capture();
|
||||||
|
Logger.Initialize();
|
||||||
|
Logger.Info(
|
||||||
|
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
|
||||||
|
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
|
||||||
|
$"LaunchSource='{commandContext.LaunchSource}'; IsElevated={execution.IsElevated}; " +
|
||||||
|
$"UserSid='{execution.UserSid ?? string.Empty}'; " +
|
||||||
|
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
|
||||||
|
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (commandContext.IsLegacyPluginInstall)
|
||||||
|
{
|
||||||
|
var installer = new PluginInstallerService();
|
||||||
|
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commandContext.IsGuiCommand)
|
||||||
|
{
|
||||||
|
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherRuntimeContext.Current = commandContext;
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
return Environment.ExitCode;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Launcher failed before GUI flow completed.", ex);
|
||||||
|
|
||||||
|
var result = new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "launcher",
|
||||||
|
Code = "launcher_bootstrap_failed",
|
||||||
|
Message = ex.Message,
|
||||||
|
ErrorMessage = ex.ToString(),
|
||||||
|
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["command"] = commandContext.Command,
|
||||||
|
["subCommand"] = commandContext.SubCommand,
|
||||||
|
["launchSource"] = commandContext.LaunchSource,
|
||||||
|
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
|
||||||
|
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
|
||||||
|
["isElevated"] = execution.IsElevated.ToString(),
|
||||||
|
["userSid"] = execution.UserSid ?? string.Empty,
|
||||||
|
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? string.Empty
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Commands.WriteResultIfNeededAsync(commandContext.GetOption("result"), result).ConfigureAwait(false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppBuilder BuildAvaloniaApp()
|
||||||
|
{
|
||||||
|
return AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||||
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"Launcher (Launch Mode)": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"commandLineArgs": "launch",
|
||||||
|
"workingDirectory": "$(SolutionDir)",
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Launcher (Update Check)": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"commandLineArgs": "update check",
|
||||||
|
"workingDirectory": "$(SolutionDir)",
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Launcher (Plugin Install)": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||||
|
"workingDirectory": "$(SolutionDir)",
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
193
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal static class Commands
|
||||||
|
{
|
||||||
|
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
|
||||||
|
{
|
||||||
|
var resultPath = context.GetOption("result");
|
||||||
|
LauncherResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = context.GetOption("source") ?? string.Empty;
|
||||||
|
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||||
|
result = installer.InstallPackage(source, pluginsDir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "plugin.install",
|
||||||
|
Code = "failed",
|
||||||
|
Message = ex.Message,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||||
|
return result.Success ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
||||||
|
{
|
||||||
|
var appRoot = ResolveAppRoot(context);
|
||||||
|
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
|
var updateEngine = new UpdateEngineService(deploymentLocator);
|
||||||
|
var pluginInstaller = new PluginInstallerService();
|
||||||
|
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||||
|
|
||||||
|
LauncherResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "command",
|
||||||
|
Code = "exception",
|
||||||
|
Message = ex.Message,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
|
||||||
|
return result.Success ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LauncherResult> ExecuteCoreAsync(
|
||||||
|
CommandContext context,
|
||||||
|
UpdateEngineService updateEngine,
|
||||||
|
PluginInstallerService pluginInstaller,
|
||||||
|
PluginUpgradeQueueService pluginUpgrades)
|
||||||
|
{
|
||||||
|
switch (context.Command.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "update":
|
||||||
|
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
|
||||||
|
case "plugin":
|
||||||
|
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
||||||
|
default:
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "command",
|
||||||
|
Code = "unsupported_command",
|
||||||
|
Message = $"Unsupported command '{context.Command}'."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||||
|
{
|
||||||
|
return context.SubCommand.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"check" => updateEngine.CheckPendingUpdate(),
|
||||||
|
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
|
||||||
|
"rollback" => updateEngine.RollbackLatest(),
|
||||||
|
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
|
||||||
|
_ => new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "update",
|
||||||
|
Code = "unsupported_subcommand",
|
||||||
|
Message = $"Unsupported update sub-command '{context.SubCommand}'."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||||
|
{
|
||||||
|
return await updateEngine.DownloadAsync(
|
||||||
|
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||||
|
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||||
|
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LauncherResult ExecutePluginCommand(
|
||||||
|
CommandContext context,
|
||||||
|
PluginInstallerService pluginInstaller,
|
||||||
|
PluginUpgradeQueueService pluginUpgrades)
|
||||||
|
{
|
||||||
|
switch (context.SubCommand.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "install":
|
||||||
|
{
|
||||||
|
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||||
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
|
return pluginInstaller.InstallPackage(source, pluginsDir);
|
||||||
|
}
|
||||||
|
case "update":
|
||||||
|
{
|
||||||
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
|
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "plugin",
|
||||||
|
Code = "unsupported_subcommand",
|
||||||
|
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(resultPath);
|
||||||
|
var dir = Path.GetDirectoryName(fullPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
|
||||||
|
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolveAppRoot(CommandContext context)
|
||||||
|
{
|
||||||
|
var configured = context.GetOption("app-root");
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
// 发布版结构:Launcher 和 app-* 目录在同一目录
|
||||||
|
// 检查当前目录是否有 app-* 子目录(发布版)
|
||||||
|
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
|
||||||
|
if (appDirs.Length > 0)
|
||||||
|
{
|
||||||
|
// 找到 app-* 目录,说明是发布版结构
|
||||||
|
return baseDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开发环境:检查父目录是否有主程序
|
||||||
|
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
|
||||||
|
var parentHost = OperatingSystem.IsWindows()
|
||||||
|
? Path.Combine(parent, "LanMountainDesktop.exe")
|
||||||
|
: Path.Combine(parent, "LanMountainDesktop");
|
||||||
|
if (File.Exists(parentHost))
|
||||||
|
{
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回 baseDir
|
||||||
|
return baseDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
|
||||||
|
{
|
||||||
|
private ISplashStageReporter? _inner;
|
||||||
|
private readonly List<(string Stage, string Message)> _pending = [];
|
||||||
|
|
||||||
|
public void SetInner(ISplashStageReporter inner)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
foreach (var (stage, message) in _pending)
|
||||||
|
{
|
||||||
|
_inner.Report(stage, message);
|
||||||
|
}
|
||||||
|
_pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Report(string stage, string message)
|
||||||
|
{
|
||||||
|
if (_inner is not null)
|
||||||
|
{
|
||||||
|
_inner.Report(stage, message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_pending.Add((stage, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReportStage(string stage, int progress)
|
||||||
|
{
|
||||||
|
if (_inner is not null)
|
||||||
|
{
|
||||||
|
_inner.ReportStage(stage, progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
616
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
616
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class DeploymentLocator
|
||||||
|
{
|
||||||
|
private readonly string _appRoot;
|
||||||
|
|
||||||
|
public DeploymentLocator(string appRoot)
|
||||||
|
{
|
||||||
|
_appRoot = appRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetAppRoot() => _appRoot;
|
||||||
|
|
||||||
|
public string? FindCurrentDeploymentDirectory()
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
|
||||||
|
|
||||||
|
if (!Directory.Exists(_appRoot))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
|
||||||
|
|
||||||
|
var validInstallations = candidates
|
||||||
|
.Where(path =>
|
||||||
|
{
|
||||||
|
var hasDestroy = File.Exists(Path.Combine(path, ".destroy"));
|
||||||
|
var hasPartial = File.Exists(Path.Combine(path, ".partial"));
|
||||||
|
var hasExe = File.Exists(Path.Combine(path, executable));
|
||||||
|
var hasCurrent = File.Exists(Path.Combine(path, ".current"));
|
||||||
|
var version = ParseVersionFromDirectory(path);
|
||||||
|
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Candidate: {Path.GetFileName(path)} | " +
|
||||||
|
$"Version={version} | " +
|
||||||
|
$"Current={hasCurrent} | " +
|
||||||
|
$"Destroy={hasDestroy} | " +
|
||||||
|
$"Partial={hasPartial} | " +
|
||||||
|
$"HasExe={hasExe}");
|
||||||
|
|
||||||
|
return !hasDestroy && !hasPartial && hasExe;
|
||||||
|
})
|
||||||
|
.Select(path => new
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
Version = ParseVersionFromDirectory(path),
|
||||||
|
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
|
||||||
|
})
|
||||||
|
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current æ ‡è®°çš„æŽ’å‰<C3A5>é<EFBFBD>¢
|
||||||
|
.ThenByDescending(x => x.Version) // ç„¶å<C2B6>ŽæŒ‰ç‰ˆæœ¬å<C2AC>·é™<C3A9>åº<C3A5>
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (validInstallations.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var best = validInstallations[0];
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
|
||||||
|
return best.Path;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[DeploymentLocator] Error searching for deployments: {ex}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HostResolutionResult ResolveHostExecutable(CommandContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||||
|
var searchedPaths = new List<string>();
|
||||||
|
var explicitAppRoot = context.ExplicitAppRoot;
|
||||||
|
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
|
||||||
|
|
||||||
|
string? resolvedPath;
|
||||||
|
string? source;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
|
||||||
|
{
|
||||||
|
var explicitRoot = Path.GetFullPath(explicitAppRoot);
|
||||||
|
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPath is null && context.IsDebugMode)
|
||||||
|
{
|
||||||
|
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPath is null)
|
||||||
|
{
|
||||||
|
resolvedPath = ResolveHostExecutablePathLegacy();
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolvedPath))
|
||||||
|
{
|
||||||
|
searchedPaths.Add(Path.GetFullPath(resolvedPath));
|
||||||
|
source = "legacy_fallback";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HostResolutionResult
|
||||||
|
{
|
||||||
|
Success = !string.IsNullOrWhiteSpace(resolvedPath),
|
||||||
|
ResolvedHostPath = resolvedPath,
|
||||||
|
ResolutionSource = source,
|
||||||
|
AppRoot = _appRoot,
|
||||||
|
ExplicitAppRoot = explicitAppRoot,
|
||||||
|
DevModeConfigIgnored = devModeConfigIgnored,
|
||||||
|
SearchedPaths = searchedPaths
|
||||||
|
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ResolveHostExecutablePath()
|
||||||
|
{
|
||||||
|
return ResolveHostExecutablePathLegacy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? TryResolveExplicitAppRoot(
|
||||||
|
string explicitRoot,
|
||||||
|
string executable,
|
||||||
|
List<string> searchedPaths,
|
||||||
|
out string? source)
|
||||||
|
{
|
||||||
|
var directPath = Path.Combine(explicitRoot, executable);
|
||||||
|
searchedPaths.Add(directPath);
|
||||||
|
if (File.Exists(directPath))
|
||||||
|
{
|
||||||
|
source = "explicit_app_root_direct";
|
||||||
|
return directPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deployment = FindBestDeploymentHost(explicitRoot, executable, searchedPaths);
|
||||||
|
if (deployment is not null)
|
||||||
|
{
|
||||||
|
source = "explicit_app_root_deployment";
|
||||||
|
return deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
source = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? TryResolvePublishedOrPortableHost(
|
||||||
|
string executable,
|
||||||
|
List<string> searchedPaths,
|
||||||
|
out string? source)
|
||||||
|
{
|
||||||
|
var deployment = FindBestDeploymentHost(_appRoot, executable, searchedPaths);
|
||||||
|
if (deployment is not null)
|
||||||
|
{
|
||||||
|
source = "published_deployment";
|
||||||
|
return deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
var portableCandidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(_appRoot, executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, executable)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in portableCandidates
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
searchedPaths.Add(candidate);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
source = "portable_host";
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? TryResolveDebugHost(
|
||||||
|
string executable,
|
||||||
|
List<string> searchedPaths,
|
||||||
|
out string? source)
|
||||||
|
{
|
||||||
|
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||||
|
{
|
||||||
|
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||||
|
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
||||||
|
{
|
||||||
|
var fullSavedPath = Path.GetFullPath(savedCustomPath);
|
||||||
|
searchedPaths.Add(fullSavedPath);
|
||||||
|
if (File.Exists(fullSavedPath))
|
||||||
|
{
|
||||||
|
source = "debug_saved_custom_path";
|
||||||
|
return fullSavedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var devPath in GetDevelopmentPaths(executable))
|
||||||
|
{
|
||||||
|
var fullPath = Path.GetFullPath(devPath);
|
||||||
|
searchedPaths.Add(fullPath);
|
||||||
|
if (File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
source = "debug_build_output";
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindBestDeploymentHost(
|
||||||
|
string root,
|
||||||
|
string executable,
|
||||||
|
List<string> searchedPaths)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(root))
|
||||||
|
{
|
||||||
|
searchedPaths.Add(Path.Combine(root, "app-*", executable));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var appDirs = Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
|
||||||
|
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||||
|
.Select(path => new
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
HostPath = Path.Combine(path, executable),
|
||||||
|
HasCurrent = File.Exists(Path.Combine(path, ".current")),
|
||||||
|
Version = ParseVersionFromDirectory(path)
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.HasCurrent)
|
||||||
|
.ThenByDescending(item => item.Version)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var candidate in appDirs)
|
||||||
|
{
|
||||||
|
searchedPaths.Add(candidate.HostPath);
|
||||||
|
if (File.Exists(candidate.HostPath))
|
||||||
|
{
|
||||||
|
return candidate.HostPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appDirs.Count == 0)
|
||||||
|
{
|
||||||
|
searchedPaths.Add(Path.Combine(root, "app-*", executable));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveHostExecutablePathLegacy()
|
||||||
|
{
|
||||||
|
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||||
|
|
||||||
|
// 1. 首先查找 app-{version} 目录(生产环境)
|
||||||
|
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||||
|
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||||
|
{
|
||||||
|
var inDeployment = Path.Combine(currentDeployment, executable);
|
||||||
|
if (File.Exists(inDeployment))
|
||||||
|
{
|
||||||
|
return inDeployment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var inRoot = Path.Combine(_appRoot, executable);
|
||||||
|
if (File.Exists(inRoot))
|
||||||
|
{
|
||||||
|
return inRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||||
|
var inParent = Path.Combine(parent, executable);
|
||||||
|
if (File.Exists(inParent))
|
||||||
|
{
|
||||||
|
return inParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. å¼€å<E282AC>‘模å¼<C3A5>:如果å<C593>¯ç”¨äº†å¼€å<E282AC>‘模å¼<C3A5>,优先使用ä¿<C3A4>å˜çš„自定义路径
|
||||||
|
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||||
|
{
|
||||||
|
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||||
|
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
|
||||||
|
{
|
||||||
|
return savedCustomPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var devPath = ScanDevelopmentPaths(executable);
|
||||||
|
if (!string.IsNullOrWhiteSpace(devPath))
|
||||||
|
{
|
||||||
|
return devPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. å¼€å<E282AC>‘模å¼<C3A5>:查找主程åº<C3A5>项目的输出目录
|
||||||
|
var devPaths = GetDevelopmentPaths(executable);
|
||||||
|
foreach (var devPath in devPaths)
|
||||||
|
{
|
||||||
|
if (File.Exists(devPath))
|
||||||
|
{
|
||||||
|
return devPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 扫æ<C2AB><C3A6>å¼€å<E282AC>‘路径(开å<E282AC>‘模å¼<C3A5>)
|
||||||
|
/// </summary>
|
||||||
|
private static string? ScanDevelopmentPaths(string executable)
|
||||||
|
{
|
||||||
|
var possiblePaths = new[]
|
||||||
|
{
|
||||||
|
// ä»?Launcher 项目è¿<C3A8>行
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<C3A8>行
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// dev-test 目录
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获å<C2B7>–å¼€å<E282AC>‘环境å<C692>¯èƒ½çš„主程åº<C3A5>è·¯å¾? /// </summary>
|
||||||
|
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||||
|
{
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
var possiblePaths = new[]
|
||||||
|
{
|
||||||
|
// ä»?Launcher 项目è¿<C3A8>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||||
|
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<C3A8>行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||||
|
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// ä»?dev-test 目录è¿<C3A8>行
|
||||||
|
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||||
|
};
|
||||||
|
|
||||||
|
return possiblePaths.Select(Path.GetFullPath).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetCurrentVersion()
|
||||||
|
{
|
||||||
|
var deployment = FindCurrentDeploymentDirectory();
|
||||||
|
if (string.IsNullOrWhiteSpace(deployment))
|
||||||
|
{
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BuildNextDeploymentDirectory(string targetVersion)
|
||||||
|
{
|
||||||
|
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
|
||||||
|
var index = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
if (!Directory.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清ç<E280A6>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<C3A4>留最近的N个版æœ? /// </summary>
|
||||||
|
/// <param name="minVersionsToKeep">最少ä¿<C3A4>留版本数,默è®?ä¸?/param>
|
||||||
|
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
|
||||||
|
|
||||||
|
if (!Directory.Exists(_appRoot))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||||
|
|
||||||
|
var validDeployments = candidates
|
||||||
|
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||||
|
.Select(path => new
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
Version = ParseVersionFromDirectory(path),
|
||||||
|
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
|
||||||
|
IsCurrent = File.Exists(Path.Combine(path, ".current"))
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.Version)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
|
||||||
|
|
||||||
|
// 确定è¦<C3A8>ä¿<C3A4>留的版本
|
||||||
|
var versionsToKeep = new HashSet<string>();
|
||||||
|
|
||||||
|
// 1. 总是ä¿<C3A4>留当å‰<C3A5>版本
|
||||||
|
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
|
||||||
|
if (currentVersion != null)
|
||||||
|
{
|
||||||
|
versionsToKeep.Add(currentVersion.Path);
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ä¿<C3A4>留最近的N个有效版本(ä¸<C3A4>åŒ…æ‹¬å·²æ ‡è®°destroy的)
|
||||||
|
var activeVersions = validDeployments
|
||||||
|
.Where(d => !d.IsDestroyed)
|
||||||
|
.Take(minVersionsToKeep)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var ver in activeVersions)
|
||||||
|
{
|
||||||
|
versionsToKeep.Add(ver.Path);
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ä¿<C3A4>留有快照的版本(用于回滚)
|
||||||
|
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||||
|
if (Directory.Exists(snapshotDir))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
|
||||||
|
foreach (var snapshotFile in snapshotFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(snapshotFile);
|
||||||
|
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
|
||||||
|
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
|
||||||
|
{
|
||||||
|
if (Directory.Exists(snapshot.SourceDirectory))
|
||||||
|
{
|
||||||
|
versionsToKeep.Add(snapshot.SourceDirectory);
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Keep version for rollback: {snapshot.SourceDirectory}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略快照解æž<C3A6>错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略快照目录访问错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清ç<E280A6>†ä¸<C3A4>需è¦<C3A8>的版本
|
||||||
|
foreach (var deployment in validDeployments)
|
||||||
|
{
|
||||||
|
if (versionsToKeep.Contains(deployment.Path))
|
||||||
|
{
|
||||||
|
if (deployment.IsDestroyed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(Path.Combine(deployment.Path, ".destroy"));
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略å<C2A5>–æ¶ˆæ ‡è®°å¤±è´¥
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deployment.IsDestroyed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// å¿½ç•¥æ ‡è®°å¤±è´¥
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// å°<C3A5>è¯•åˆ é™¤
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(deployment.Path, recursive: true);
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// å¿½ç•¥åˆ é™¤å¤±è´¥(å<>¯èƒ½æ–‡ä»¶è¢«å<C2AB> ç”?,下次å<C2A1>¯åЍå†<C3A5>试
|
||||||
|
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
|
||||||
|
// 忽略清ç<E280A6>†å¤±è´¥
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 仅清ç<E280A6>†å·²æ ‡è®°ä¸?destroy的部署(兼容旧方法)
|
||||||
|
/// </summary>
|
||||||
|
[Obsolete("Use CleanupOldDeployments instead")]
|
||||||
|
public void CleanupDestroyedDeployments()
|
||||||
|
{
|
||||||
|
CleanupOldDeployments(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Version ParseVersionFromDirectory(string path)
|
||||||
|
{
|
||||||
|
var text = ParseVersionTextFromDirectory(path);
|
||||||
|
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ParseVersionTextFromDirectory(string path)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = fileName.Split('-');
|
||||||
|
if (segments.Length < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从部署目录读å<C2BB>–版本信æ<C2A1>? /// </summary>
|
||||||
|
public AppVersionInfo GetVersionInfo()
|
||||||
|
{
|
||||||
|
var deploymentDir = FindCurrentDeploymentDirectory();
|
||||||
|
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||||
|
{
|
||||||
|
var versionFile = Path.Combine(deploymentDir, "version.json");
|
||||||
|
if (File.Exists(versionFile))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(versionFile);
|
||||||
|
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
|
||||||
|
if (info is not null)
|
||||||
|
{
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AppVersionInfo
|
||||||
|
{
|
||||||
|
Version = GetCurrentVersion(),
|
||||||
|
Codename = "Administrate"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
629
LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
Normal file
629
LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 灵活的主程序定位器
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FlexibleHostLocator
|
||||||
|
{
|
||||||
|
private readonly HostDiscoveryOptions _options;
|
||||||
|
private readonly string _appRoot;
|
||||||
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
|
|
||||||
|
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
||||||
|
{
|
||||||
|
_appRoot = appRoot;
|
||||||
|
_options = options ?? new HostDiscoveryOptions();
|
||||||
|
_deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析主程序可执行文件路径
|
||||||
|
/// </summary>
|
||||||
|
public string? ResolveHostExecutablePath()
|
||||||
|
{
|
||||||
|
var executable = GetExecutableName();
|
||||||
|
var searchContext = new SearchContext
|
||||||
|
{
|
||||||
|
ExecutableName = executable,
|
||||||
|
AppRoot = _appRoot,
|
||||||
|
Options = _options
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 第一阶段:标准路径查找(快速路径)==========
|
||||||
|
|
||||||
|
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
|
||||||
|
var envPath = GetPathFromEnvironment();
|
||||||
|
if (!string.IsNullOrWhiteSpace(envPath))
|
||||||
|
{
|
||||||
|
var validated = ValidateAndReturn(envPath, "environment variable");
|
||||||
|
if (validated != null) return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 使用 DeploymentLocator(ClassIsland 风格的简洁查询 - 优先)
|
||||||
|
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
|
||||||
|
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||||
|
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||||
|
{
|
||||||
|
var deploymentExePath = Path.Combine(deploymentDir, executable);
|
||||||
|
if (File.Exists(deploymentExePath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
|
||||||
|
return deploymentExePath;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
|
||||||
|
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
|
||||||
|
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
||||||
|
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
||||||
|
{
|
||||||
|
return deploymentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 检查 Launcher 同级目录(便携模式)
|
||||||
|
var portablePath = SearchPortableLocation(searchContext);
|
||||||
|
if (!string.IsNullOrWhiteSpace(portablePath))
|
||||||
|
{
|
||||||
|
return portablePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
|
||||||
|
|
||||||
|
// 5. 检查配置文件中的路径 - 用户自定义配置
|
||||||
|
var configPath = GetPathFromConfigFile();
|
||||||
|
if (!string.IsNullOrWhiteSpace(configPath))
|
||||||
|
{
|
||||||
|
var validated = ValidateAndReturn(configPath, "config file");
|
||||||
|
if (validated != null) return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 搜索附近目录(向上、向下各一层)
|
||||||
|
var nearbyPath = SearchNearbyDirectories(searchContext);
|
||||||
|
if (!string.IsNullOrWhiteSpace(nearbyPath))
|
||||||
|
{
|
||||||
|
return nearbyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 开发模式:检查保存的自定义路径
|
||||||
|
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
|
||||||
|
{
|
||||||
|
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||||
|
if (!string.IsNullOrWhiteSpace(savedPath))
|
||||||
|
{
|
||||||
|
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
|
||||||
|
if (validated != null) return validated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 搜索标准开发路径
|
||||||
|
var devPath = SearchDevelopmentPaths(searchContext);
|
||||||
|
if (!string.IsNullOrWhiteSpace(devPath))
|
||||||
|
{
|
||||||
|
return devPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 搜索额外的配置路径
|
||||||
|
var additionalPath = SearchAdditionalPaths(searchContext);
|
||||||
|
if (!string.IsNullOrWhiteSpace(additionalPath))
|
||||||
|
{
|
||||||
|
return additionalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 递归搜索(如果启用)
|
||||||
|
if (_options.RecursiveSearch)
|
||||||
|
{
|
||||||
|
var recursivePath = SearchRecursively(searchContext);
|
||||||
|
if (!string.IsNullOrWhiteSpace(recursivePath))
|
||||||
|
{
|
||||||
|
return recursivePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从环境变量获取路径
|
||||||
|
/// </summary>
|
||||||
|
private string? GetPathFromEnvironment()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从配置文件获取路径
|
||||||
|
/// </summary>
|
||||||
|
private string? GetPathFromConfigFile()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
|
||||||
|
if (!File.Exists(configPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(configPath);
|
||||||
|
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
|
||||||
|
if (config?.HostPath != null && File.Exists(config.HostPath))
|
||||||
|
{
|
||||||
|
return config.HostPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略配置文件读取错误
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索部署目录
|
||||||
|
/// </summary>
|
||||||
|
private string? SearchDeploymentDirectories(SearchContext context)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_appRoot))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 查找 app-* 目录
|
||||||
|
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
|
||||||
|
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 优先选择带 .current 标记的
|
||||||
|
var currentMarked = appDirs
|
||||||
|
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
|
||||||
|
.Select(dir => Path.Combine(dir, context.ExecutableName))
|
||||||
|
.FirstOrDefault(File.Exists);
|
||||||
|
|
||||||
|
if (currentMarked != null)
|
||||||
|
{
|
||||||
|
return currentMarked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择版本号最高的
|
||||||
|
var latest = appDirs
|
||||||
|
.Select(dir => new
|
||||||
|
{
|
||||||
|
Dir = dir,
|
||||||
|
Version = ParseVersionFromDirectoryName(dir)
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Version)
|
||||||
|
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
|
||||||
|
.FirstOrDefault(File.Exists);
|
||||||
|
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索便携模式位置(Launcher 同级目录)
|
||||||
|
/// </summary>
|
||||||
|
private string? SearchPortableLocation(SearchContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
|
||||||
|
|
||||||
|
if (File.Exists(portablePath))
|
||||||
|
{
|
||||||
|
return portablePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索附近目录(灵活查找,适用于各种部署场景)
|
||||||
|
/// </summary>
|
||||||
|
private string? SearchNearbyDirectories(SearchContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var searchDirs = new List<string>();
|
||||||
|
|
||||||
|
// Launcher 所在目录
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
searchDirs.Add(launcherDir);
|
||||||
|
|
||||||
|
// 上级目录
|
||||||
|
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
|
||||||
|
if (Directory.Exists(parentDir))
|
||||||
|
{
|
||||||
|
searchDirs.Add(parentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上上级目录
|
||||||
|
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
|
||||||
|
if (Directory.Exists(grandparentDir))
|
||||||
|
{
|
||||||
|
searchDirs.Add(grandparentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppRoot 及其上级
|
||||||
|
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
|
||||||
|
{
|
||||||
|
searchDirs.Add(_appRoot);
|
||||||
|
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||||
|
if (Directory.Exists(appParent))
|
||||||
|
{
|
||||||
|
searchDirs.Add(appParent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重后搜索
|
||||||
|
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// 直接搜索
|
||||||
|
var directPath = Path.Combine(dir, context.ExecutableName);
|
||||||
|
if (File.Exists(directPath))
|
||||||
|
{
|
||||||
|
return directPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索子目录(一层)
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
foreach (var subDir in Directory.GetDirectories(dir))
|
||||||
|
{
|
||||||
|
var subPath = Path.Combine(subDir, context.ExecutableName);
|
||||||
|
if (File.Exists(subPath))
|
||||||
|
{
|
||||||
|
return subPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略搜索错误
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索开发路径
|
||||||
|
/// </summary>
|
||||||
|
private string? SearchDevelopmentPaths(SearchContext context)
|
||||||
|
{
|
||||||
|
// 获取 Launcher 所在目录
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
// 动态构建可能的开发路径(支持不同的项目结构)
|
||||||
|
var possiblePaths = new List<string>();
|
||||||
|
|
||||||
|
// 从解决方案根目录搜索(支持不同的解决方案结构)
|
||||||
|
var solutionRoot = FindSolutionRoot(launcherDir);
|
||||||
|
if (!string.IsNullOrWhiteSpace(solutionRoot))
|
||||||
|
{
|
||||||
|
// 搜索所有可能的 bin 目录
|
||||||
|
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加硬编码的备用路径
|
||||||
|
possiblePaths.AddRange(new[]
|
||||||
|
{
|
||||||
|
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
||||||
|
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
||||||
|
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
||||||
|
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索额外的配置路径
|
||||||
|
/// </summary>
|
||||||
|
private string? SearchAdditionalPaths(SearchContext context)
|
||||||
|
{
|
||||||
|
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pattern in _options.AdditionalSearchPaths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 替换变量
|
||||||
|
var expandedPattern = ExpandVariables(pattern);
|
||||||
|
|
||||||
|
// 支持通配符
|
||||||
|
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
|
||||||
|
var filePattern = Path.GetFileName(expandedPattern);
|
||||||
|
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
|
||||||
|
var validMatch = matches.FirstOrDefault(File.Exists);
|
||||||
|
if (validMatch != null)
|
||||||
|
{
|
||||||
|
return validMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (File.Exists(expandedPattern))
|
||||||
|
{
|
||||||
|
return expandedPattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略搜索错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归搜索
|
||||||
|
/// </summary>
|
||||||
|
private string? SearchRecursively(SearchContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
|
||||||
|
|
||||||
|
foreach (var searchDir in searchDirs.Where(Directory.Exists))
|
||||||
|
{
|
||||||
|
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略递归搜索错误
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归搜索目录
|
||||||
|
/// </summary>
|
||||||
|
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
|
||||||
|
{
|
||||||
|
if (depth > _options.MaxRecursionDepth)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 检查当前目录
|
||||||
|
var directPath = Path.Combine(dir, executableName);
|
||||||
|
if (File.Exists(directPath))
|
||||||
|
{
|
||||||
|
return directPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查子目录
|
||||||
|
foreach (var subDir in Directory.GetDirectories(dir))
|
||||||
|
{
|
||||||
|
// 跳过某些目录
|
||||||
|
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
|
||||||
|
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略访问错误
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找解决方案根目录
|
||||||
|
/// </summary>
|
||||||
|
private string? FindSolutionRoot(string startDir)
|
||||||
|
{
|
||||||
|
var current = new DirectoryInfo(startDir);
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
// 查找 .sln 文件
|
||||||
|
if (current.GetFiles("*.sln").Any())
|
||||||
|
{
|
||||||
|
return current.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找 .git 目录作为备选
|
||||||
|
if (current.GetDirectories(".git").Any())
|
||||||
|
{
|
||||||
|
return current.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索 bin 目录
|
||||||
|
/// </summary>
|
||||||
|
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
|
||||||
|
{
|
||||||
|
var results = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 查找所有 bin 目录
|
||||||
|
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
|
||||||
|
|
||||||
|
foreach (var binDir in binDirs)
|
||||||
|
{
|
||||||
|
// 检查 Debug 和 Release 子目录
|
||||||
|
var configDirs = new[] { "Debug", "Release" };
|
||||||
|
foreach (var config in configDirs)
|
||||||
|
{
|
||||||
|
var configPath = Path.Combine(binDir, config);
|
||||||
|
if (Directory.Exists(configPath))
|
||||||
|
{
|
||||||
|
// 检查所有 net* 子目录
|
||||||
|
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
|
||||||
|
foreach (var fwDir in frameworkDirs)
|
||||||
|
{
|
||||||
|
var exePath = Path.Combine(fwDir, executableName);
|
||||||
|
if (File.Exists(exePath))
|
||||||
|
{
|
||||||
|
results.Add(exePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略搜索错误
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证路径并返回
|
||||||
|
/// </summary>
|
||||||
|
private string? ValidateAndReturn(string path, string source)
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Found host executable from {source}: {path}");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试添加 .exe(Windows)
|
||||||
|
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var withExe = path + ".exe";
|
||||||
|
if (File.Exists(withExe))
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Found host executable from {source}: {withExe}");
|
||||||
|
return withExe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取可执行文件名
|
||||||
|
/// </summary>
|
||||||
|
private string GetExecutableName()
|
||||||
|
{
|
||||||
|
var name = _options.ExecutableName;
|
||||||
|
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
name += ".exe";
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展开路径变量
|
||||||
|
/// </summary>
|
||||||
|
private string ExpandVariables(string path)
|
||||||
|
{
|
||||||
|
return path
|
||||||
|
.Replace("${AppRoot}", _appRoot)
|
||||||
|
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
|
||||||
|
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||||
|
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从目录名解析版本
|
||||||
|
/// </summary>
|
||||||
|
private static Version ParseVersionFromDirectoryName(string path)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
return new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = fileName.Split('-');
|
||||||
|
if (segments.Length < 2)
|
||||||
|
{
|
||||||
|
return new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索上下文
|
||||||
|
/// </summary>
|
||||||
|
private class SearchContext
|
||||||
|
{
|
||||||
|
public required string ExecutableName { get; set; }
|
||||||
|
public required string AppRoot { get; set; }
|
||||||
|
public required HostDiscoveryOptions Options { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发现配置文件
|
||||||
|
/// </summary>
|
||||||
|
internal class HostDiscoveryConfig
|
||||||
|
{
|
||||||
|
public string? HostPath { get; set; }
|
||||||
|
public List<string>? AdditionalPaths { get; set; }
|
||||||
|
}
|
||||||
47
LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
Normal file
47
LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主程序发现选项
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HostDiscoveryOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 可执行文件名(Windows 下自动添加 .exe)
|
||||||
|
/// </summary>
|
||||||
|
public string ExecutableName { get; set; } = "LanMountainDesktop";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 额外的搜索路径(支持通配符)
|
||||||
|
/// </summary>
|
||||||
|
public List<string> AdditionalSearchPaths { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否递归搜索子目录
|
||||||
|
/// </summary>
|
||||||
|
public bool RecursiveSearch { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归搜索的最大深度
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRecursionDepth { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 环境变量名称,用于指定自定义路径
|
||||||
|
/// </summary>
|
||||||
|
public string? CustomPathEnvVar { get; set; } = "LMD_HOST_PATH";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配置文件路径(相对于 app root)
|
||||||
|
/// </summary>
|
||||||
|
public string? ConfigFileName { get; set; } = "host-discovery.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否优先使用开发模式配置
|
||||||
|
/// </summary>
|
||||||
|
public bool PreferDevModeConfig { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索超时(毫秒)
|
||||||
|
/// </summary>
|
||||||
|
public int SearchTimeoutMs { get; set; } = 5000;
|
||||||
|
}
|
||||||
18
LanMountainDesktop.Launcher/Services/HostResolutionResult.cs
Normal file
18
LanMountainDesktop.Launcher/Services/HostResolutionResult.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class HostResolutionResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string? ResolvedHostPath { get; init; }
|
||||||
|
|
||||||
|
public string? ResolutionSource { get; init; }
|
||||||
|
|
||||||
|
public string AppRoot { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? ExplicitAppRoot { get; init; }
|
||||||
|
|
||||||
|
public bool DevModeConfigIgnored { get; init; }
|
||||||
|
|
||||||
|
public List<string> SearchedPaths { get; init; } = [];
|
||||||
|
}
|
||||||
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal interface IOobeStep
|
||||||
|
{
|
||||||
|
Task RunAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
11
LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
Normal file
11
LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal interface ISplashStageReporter
|
||||||
|
{
|
||||||
|
void Report(string stage, string message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报告阶段和进度(0-100)
|
||||||
|
/// </summary>
|
||||||
|
void ReportStage(string stage, int progress);
|
||||||
|
}
|
||||||
192
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
192
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
|
||||||
|
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
|
||||||
|
/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
|
||||||
|
/// </summary>
|
||||||
|
public class LauncherIpcServer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly Action<StartupProgressMessage> _onProgress;
|
||||||
|
private Task? _listenTask;
|
||||||
|
private NamedPipeServerStream? _currentPipe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||||
|
/// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
|
||||||
|
/// </summary>
|
||||||
|
private const int LengthPrefixSize = 4;
|
||||||
|
|
||||||
|
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||||
|
{
|
||||||
|
_onProgress = onProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动 IPC 服务端监听
|
||||||
|
/// </summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenLoopAsync()
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
NamedPipeServerStream? pipe = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe = new NamedPipeServerStream(
|
||||||
|
LauncherIpcConstants.PipeName,
|
||||||
|
PipeDirection.In,
|
||||||
|
1,
|
||||||
|
PipeTransmissionMode.Byte);
|
||||||
|
|
||||||
|
_currentPipe = pipe;
|
||||||
|
await pipe.WaitForConnectionAsync(_cts.Token);
|
||||||
|
|
||||||
|
// 持久连接:在同一连接上循环读取多条消息,直到客户端断开
|
||||||
|
await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// 客户端断开连接,继续等待新连接
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"IPC listen error: {ex.Message}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(200, _cts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe?.Dispose();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
if (ReferenceEquals(_currentPipe, pipe))
|
||||||
|
{
|
||||||
|
_currentPipe = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从已连接的管道中持续读取消息,直到连接断开或取消
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// 1. 读取 4 字节长度前缀
|
||||||
|
var totalRead = 0;
|
||||||
|
while (totalRead < LengthPrefixSize)
|
||||||
|
{
|
||||||
|
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
// 连接已关闭
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
totalRead += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
|
||||||
|
if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
|
||||||
|
{
|
||||||
|
// 无效长度,跳过此连接
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取消息正文
|
||||||
|
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
totalRead = 0;
|
||||||
|
while (totalRead < payloadLength)
|
||||||
|
{
|
||||||
|
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
totalRead += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 反序列化并回调
|
||||||
|
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||||
|
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
|
||||||
|
if (message is not null)
|
||||||
|
{
|
||||||
|
_onProgress(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// 忽略解析错误,继续读取下一条消息
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(payloadBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(lengthBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 停止 IPC 服务端
|
||||||
|
/// </summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_currentPipe?.Dispose();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
_cts.Dispose();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Security.Principal;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal static class LauncherExecutionContext
|
||||||
|
{
|
||||||
|
public static LauncherExecutionSnapshot Capture()
|
||||||
|
{
|
||||||
|
var userName = Environment.UserName ?? string.Empty;
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return new LauncherExecutionSnapshot(false, userName, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var principal = new WindowsPrincipal(identity);
|
||||||
|
return new LauncherExecutionSnapshot(
|
||||||
|
principal.IsInRole(WindowsBuiltInRole.Administrator),
|
||||||
|
userName,
|
||||||
|
identity.User?.Value);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new LauncherExecutionSnapshot(false, userName, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
944
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
944
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class LauncherFlowCoordinator
|
||||||
|
{
|
||||||
|
private static readonly string[] LauncherOnlyOptions =
|
||||||
|
[
|
||||||
|
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||||
|
"app-root", "launch-source",
|
||||||
|
LauncherIpcConstants.LauncherPidEnvVar,
|
||||||
|
LauncherIpcConstants.PackageRootEnvVar,
|
||||||
|
LauncherIpcConstants.VersionEnvVar,
|
||||||
|
LauncherIpcConstants.CodenameEnvVar
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly CommandContext _context;
|
||||||
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
|
private readonly OobeStateService _oobeStateService;
|
||||||
|
private readonly UpdateEngineService _updateEngine;
|
||||||
|
private readonly PluginInstallerService _pluginInstallerService;
|
||||||
|
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||||
|
|
||||||
|
public LauncherFlowCoordinator(
|
||||||
|
CommandContext context,
|
||||||
|
DeploymentLocator deploymentLocator,
|
||||||
|
OobeStateService oobeStateService,
|
||||||
|
UpdateEngineService updateEngine,
|
||||||
|
PluginInstallerService pluginInstallerService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_deploymentLocator = deploymentLocator;
|
||||||
|
_oobeStateService = oobeStateService;
|
||||||
|
_updateEngine = updateEngine;
|
||||||
|
_pluginInstallerService = pluginInstallerService;
|
||||||
|
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||||
|
var oobeDecision = _oobeStateService.Evaluate(_context);
|
||||||
|
var launcherContextDetails = BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot());
|
||||||
|
|
||||||
|
if (oobeDecision.ShouldShowOobe)
|
||||||
|
{
|
||||||
|
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
||||||
|
if (legacyInfo is not null)
|
||||||
|
{
|
||||||
|
var migrationResult = await ShowMigrationPromptAsync(legacyInfo).ConfigureAwait(false);
|
||||||
|
Logger.Info($"Migration prompt completed. Result='{migrationResult}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var window = new SplashWindow();
|
||||||
|
window.Show();
|
||||||
|
return window;
|
||||||
|
});
|
||||||
|
var reporter = (ISplashStageReporter)splashWindow;
|
||||||
|
|
||||||
|
LoadingDetailsWindow? loadingDetailsWindow = null;
|
||||||
|
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
loadingDetailsWindow = new LoadingDetailsWindow();
|
||||||
|
loadingDetailsWindow.Show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibilityTcs = new TaskCompletionSource<StartupStage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var activationFailedTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var lastStage = StartupStage.Initializing;
|
||||||
|
var lastStageMessage = "launcher-started";
|
||||||
|
|
||||||
|
var loadingState = new LoadingStateMessage();
|
||||||
|
using var ipcServer = new LauncherIpcServer(message =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lastStage = message.Stage;
|
||||||
|
lastStageMessage = message.Message ?? string.Empty;
|
||||||
|
Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'.");
|
||||||
|
|
||||||
|
loadingState = loadingState with
|
||||||
|
{
|
||||||
|
Stage = message.Stage,
|
||||||
|
OverallProgressPercent = message.ProgressPercent,
|
||||||
|
Message = message.Message,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
|
||||||
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||||
|
|
||||||
|
switch (message.Stage)
|
||||||
|
{
|
||||||
|
case StartupStage.DesktopVisible:
|
||||||
|
case StartupStage.ActivationRedirected:
|
||||||
|
visibilityTcs.TrySetResult(message.Stage);
|
||||||
|
break;
|
||||||
|
case StartupStage.ActivationFailed:
|
||||||
|
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("IPC progress callback failed.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ipcServer.Start();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reporter.Report("update", "Checking updates...");
|
||||||
|
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||||
|
if (!updateResult.Success)
|
||||||
|
{
|
||||||
|
return WithAdditionalDetails(updateResult, launcherContextDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
reporter.Report("plugins", "Applying plugin upgrades...");
|
||||||
|
var pluginsDir = _context.GetOption("plugins-dir") ?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||||
|
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||||
|
if (!queueResult.Success)
|
||||||
|
{
|
||||||
|
return WithAdditionalDetails(queueResult, launcherContextDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oobeDecision.ShouldShowOobe)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
||||||
|
foreach (var step in _oobeSteps)
|
||||||
|
{
|
||||||
|
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
|
||||||
|
}
|
||||||
|
|
||||||
|
reporter.Report("launch", "Launching desktop...");
|
||||||
|
var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
|
||||||
|
if (!launchOutcome.Result.Success)
|
||||||
|
{
|
||||||
|
return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launchOutcome.ImmediateResult is not null)
|
||||||
|
{
|
||||||
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
|
return WithAdditionalDetails(launchOutcome.ImmediateResult, launcherContextDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launchOutcome.Process is null)
|
||||||
|
{
|
||||||
|
return BuildResult(
|
||||||
|
success: false,
|
||||||
|
stage: "launch",
|
||||||
|
code: "host_start_failed",
|
||||||
|
message: "Host launch did not create a process.",
|
||||||
|
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||||
|
}
|
||||||
|
|
||||||
|
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||||
|
var completedTask = await Task.WhenAny(
|
||||||
|
visibilityTcs.Task,
|
||||||
|
activationFailedTcs.Task,
|
||||||
|
processExitTask,
|
||||||
|
Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (completedTask == visibilityTcs.Task)
|
||||||
|
{
|
||||||
|
var stage = await visibilityTcs.Task.ConfigureAwait(false);
|
||||||
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
|
return BuildResult(
|
||||||
|
success: true,
|
||||||
|
stage: "launch",
|
||||||
|
code: stage == StartupStage.ActivationRedirected ? "activation_redirected" : "ok",
|
||||||
|
message: stage == StartupStage.ActivationRedirected
|
||||||
|
? "Launcher activation was redirected to the existing desktop instance."
|
||||||
|
: "Desktop is visible and ready.",
|
||||||
|
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedTask == activationFailedTcs.Task)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Activation failure received before desktop visibility. Reason='{await activationFailedTcs.Task.ConfigureAwait(false)}'.");
|
||||||
|
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||||
|
if (retryOutcome is not null)
|
||||||
|
{
|
||||||
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
|
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedTask == processExitTask)
|
||||||
|
{
|
||||||
|
var exitCode = launchOutcome.Process.ExitCode;
|
||||||
|
Logger.Warn($"Host exited before desktop became visible. ExitCode={exitCode}.");
|
||||||
|
|
||||||
|
if (exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
|
||||||
|
{
|
||||||
|
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||||
|
if (retryOutcome is not null)
|
||||||
|
{
|
||||||
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
|
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
|
return BuildResult(
|
||||||
|
success: false,
|
||||||
|
stage: "launch",
|
||||||
|
code: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_redirected" : "host_exited_early",
|
||||||
|
message: exitCode == HostExitCodes.SecondaryActivationSucceeded
|
||||||
|
? "Host redirected activation to the existing desktop instance."
|
||||||
|
: $"Host exited before the desktop became visible. ExitCode={exitCode}.",
|
||||||
|
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["exitCode"] = exitCode.ToString()
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
|
return BuildResult(
|
||||||
|
success: false,
|
||||||
|
stage: "launch",
|
||||||
|
code: "desktop_not_visible",
|
||||||
|
message: "Host process started, but the desktop never became visible within 30 seconds.",
|
||||||
|
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["ipcStage"] = lastStage.ToString(),
|
||||||
|
["ipcMessage"] = lastStageMessage
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||||
|
{
|
||||||
|
splashWindow.Close();
|
||||||
|
Logger.Info("Splash window closed in coordinator cleanup.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close splash window during coordinator cleanup.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Launcher coordinator failed.", ex);
|
||||||
|
return BuildResult(
|
||||||
|
success: false,
|
||||||
|
stage: "launch",
|
||||||
|
code: "exception",
|
||||||
|
message: ex.Message,
|
||||||
|
details: BuildLauncherContextDetails(_context, _oobeStateService.Evaluate(_context), _deploymentLocator.GetAppRoot()),
|
||||||
|
errorMessage: ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LauncherResult?> RetryActivationAfterEarlyFailureAsync()
|
||||||
|
{
|
||||||
|
Logger.Warn("Attempting one explicit activation retry after host early failure.");
|
||||||
|
var retryOutcome = await LaunchHostWithIpcAsync(forceDirectMode: true, retryTag: "explicit-activation-retry").ConfigureAwait(false);
|
||||||
|
if (!retryOutcome.Result.Success)
|
||||||
|
{
|
||||||
|
return retryOutcome.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryOutcome.ImmediateResult is not null)
|
||||||
|
{
|
||||||
|
return retryOutcome.ImmediateResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryOutcome.Process is not null)
|
||||||
|
{
|
||||||
|
var retryExitTask = retryOutcome.Process.WaitForExitAsync();
|
||||||
|
var completed = await Task.WhenAny(retryExitTask, Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (completed != retryExitTask)
|
||||||
|
{
|
||||||
|
return BuildResult(
|
||||||
|
success: true,
|
||||||
|
stage: "launch",
|
||||||
|
code: "activation_retry_started",
|
||||||
|
message: "Activation retry started the host successfully.",
|
||||||
|
details: retryOutcome.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryOutcome.Process.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||||
|
{
|
||||||
|
return BuildResult(
|
||||||
|
success: true,
|
||||||
|
stage: "launch",
|
||||||
|
code: "activation_redirected",
|
||||||
|
message: "Activation retry redirected to the existing desktop instance.",
|
||||||
|
details: retryOutcome.Details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildResult(
|
||||||
|
success: false,
|
||||||
|
stage: "launch",
|
||||||
|
code: "activation_failed",
|
||||||
|
message: "Activation retry failed to make the desktop visible.",
|
||||||
|
details: retryOutcome.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||||
|
{
|
||||||
|
splashWindow.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close splash window.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||||
|
{
|
||||||
|
loadingDetailsWindow.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close loading details window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HostLaunchOutcome> LaunchHostWithIpcAsync(bool forceDirectMode = false, string? retryTag = null)
|
||||||
|
{
|
||||||
|
var resolution = _deploymentLocator.ResolveHostExecutable(_context);
|
||||||
|
if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
||||||
|
{
|
||||||
|
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync().ConfigureAwait(false);
|
||||||
|
if (errorResult == ErrorWindowResult.Retry)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath))
|
||||||
|
{
|
||||||
|
return await LaunchHostWithExplicitPathAsync(selectedPath, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await LaunchHostWithIpcAsync(forceDirectMode, retryTag).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostLaunchOutcome.FromResult(BuildResult(
|
||||||
|
success: false,
|
||||||
|
stage: "launchHost",
|
||||||
|
code: "host_not_found",
|
||||||
|
message: "LanMountainDesktop host executable was not found.",
|
||||||
|
details: BuildResolutionDetails(resolution, null, null, "resolve")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<HostLaunchOutcome> LaunchHostWithExplicitPathAsync(string hostPath, bool forceDirectMode, string? retryTag)
|
||||||
|
{
|
||||||
|
var resolution = new HostResolutionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ResolvedHostPath = Path.GetFullPath(hostPath),
|
||||||
|
ResolutionSource = "user_selected_path",
|
||||||
|
AppRoot = _deploymentLocator.GetAppRoot(),
|
||||||
|
ExplicitAppRoot = Path.GetDirectoryName(hostPath),
|
||||||
|
SearchedPaths = [Path.GetFullPath(hostPath)]
|
||||||
|
};
|
||||||
|
|
||||||
|
return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
bool forceDirectMode,
|
||||||
|
string? retryTag)
|
||||||
|
{
|
||||||
|
var hostPath = resolution.ResolvedHostPath!;
|
||||||
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
EnsureExecutable(hostPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostWorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
|
||||||
|
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||||
|
var forwardedArguments = BuildForwardedArguments(versionInfo);
|
||||||
|
|
||||||
|
var primaryMode = forceDirectMode || !OperatingSystem.IsWindows()
|
||||||
|
? HostStartMode.Direct
|
||||||
|
: HostStartMode.ShellExecute;
|
||||||
|
var fallbackMode = primaryMode == HostStartMode.ShellExecute
|
||||||
|
? HostStartMode.Direct
|
||||||
|
: (HostStartMode?)null;
|
||||||
|
|
||||||
|
var firstAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, primaryMode, retryTag).ConfigureAwait(false);
|
||||||
|
if (firstAttempt.ProcessCreated && !firstAttempt.ExitedEarly && firstAttempt.Process is not null)
|
||||||
|
{
|
||||||
|
var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null);
|
||||||
|
return HostLaunchOutcome.FromProcess(
|
||||||
|
firstAttempt.Process,
|
||||||
|
BuildResult(true, "launchHost", "ok", "Host launched.", firstDetails),
|
||||||
|
firstDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackMode is null)
|
||||||
|
{
|
||||||
|
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Warn(
|
||||||
|
$"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " +
|
||||||
|
$"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'.");
|
||||||
|
|
||||||
|
var secondAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, fallbackMode.Value, retryTag).ConfigureAwait(false);
|
||||||
|
if (secondAttempt.ProcessCreated && !secondAttempt.ExitedEarly && secondAttempt.Process is not null)
|
||||||
|
{
|
||||||
|
var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null);
|
||||||
|
return HostLaunchOutcome.FromProcess(
|
||||||
|
secondAttempt.Process,
|
||||||
|
BuildResult(true, "launchHost", "ok", "Host launched.", details),
|
||||||
|
details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HostLaunchOutcome BuildOutcomeFromAttempt(
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
HostStartAttempt finalAttempt,
|
||||||
|
HostStartAttempt? previousAttempt)
|
||||||
|
{
|
||||||
|
var details = BuildResolutionDetails(
|
||||||
|
resolution,
|
||||||
|
previousAttempt ?? finalAttempt,
|
||||||
|
previousAttempt is null ? null : finalAttempt,
|
||||||
|
!finalAttempt.ProcessCreated
|
||||||
|
? "start"
|
||||||
|
: finalAttempt.ExitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
|
||||||
|
? "activation"
|
||||||
|
: "early-exit");
|
||||||
|
|
||||||
|
if (!finalAttempt.ProcessCreated)
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromResult(BuildResult(
|
||||||
|
false,
|
||||||
|
"launchHost",
|
||||||
|
"host_start_failed",
|
||||||
|
$"Failed to start host using start mode '{finalAttempt.StartMode}'.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromImmediateResult(BuildResult(
|
||||||
|
true,
|
||||||
|
"launch",
|
||||||
|
"activation_redirected",
|
||||||
|
"Launcher activation was redirected to the existing desktop instance.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalAttempt.ExitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
|
||||||
|
{
|
||||||
|
return HostLaunchOutcome.FromResult(BuildResult(
|
||||||
|
false,
|
||||||
|
"launch",
|
||||||
|
"activation_failed",
|
||||||
|
$"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostLaunchOutcome.FromResult(BuildResult(
|
||||||
|
false,
|
||||||
|
"launchHost",
|
||||||
|
"host_exited_early",
|
||||||
|
$"Host exited early using start mode '{finalAttempt.StartMode}'.",
|
||||||
|
details));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HostStartAttempt> StartHostProcessAsync(
|
||||||
|
string hostPath,
|
||||||
|
string hostWorkingDirectory,
|
||||||
|
string arguments,
|
||||||
|
AppVersionInfo versionInfo,
|
||||||
|
HostStartMode startMode,
|
||||||
|
string? retryTag)
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = hostPath,
|
||||||
|
WorkingDirectory = hostWorkingDirectory,
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = startMode == HostStartMode.ShellExecute
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startMode == HostStartMode.Direct)
|
||||||
|
{
|
||||||
|
startInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString();
|
||||||
|
startInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] = _deploymentLocator.GetAppRoot();
|
||||||
|
startInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
|
||||||
|
startInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var process = Process.Start(startInfo);
|
||||||
|
Logger.Info(
|
||||||
|
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{hostPath}'; " +
|
||||||
|
$"WorkingDir='{hostWorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; Args='{startInfo.Arguments}'.");
|
||||||
|
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null");
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitTask = process.WaitForExitAsync();
|
||||||
|
var completed = await Task.WhenAny(exitTask, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false);
|
||||||
|
if (completed == exitTask)
|
||||||
|
{
|
||||||
|
return HostStartAttempt.EarlyExit(startMode, process, process.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostStartAttempt.Started(startMode, process);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error($"Host start failed. Mode='{startMode}'.", ex);
|
||||||
|
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildForwardedArguments(AppVersionInfo versionInfo)
|
||||||
|
{
|
||||||
|
var arguments = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
for (var index = 0; index < _context.RawArgs.Count; index++)
|
||||||
|
{
|
||||||
|
var arg = _context.RawArgs[index];
|
||||||
|
|
||||||
|
if (arg == _context.Command || arg == _context.SubCommand)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var key = arg[2..];
|
||||||
|
var equalsIndex = key.IndexOf('=');
|
||||||
|
if (equalsIndex >= 0)
|
||||||
|
{
|
||||||
|
key = key[..equalsIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (equalsIndex < 0 &&
|
||||||
|
index + 1 < _context.RawArgs.Count &&
|
||||||
|
!_context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arguments.Length > 0)
|
||||||
|
{
|
||||||
|
arguments.Append(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.Append(QuoteArgument(arg));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arguments.Length > 0)
|
||||||
|
{
|
||||||
|
arguments.Append(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||||
|
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
|
||||||
|
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||||
|
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={QuoteArgument(versionInfo.Codename)}");
|
||||||
|
|
||||||
|
return arguments.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||||
|
{
|
||||||
|
ErrorWindow? errorWindow = null;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
errorWindow = new ErrorWindow();
|
||||||
|
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
|
||||||
|
errorWindow.Show();
|
||||||
|
Logger.Warn("Host not found. Showing error window.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to show host-not-found error window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errorWindow is null)
|
||||||
|
{
|
||||||
|
return (ErrorWindowResult.Exit, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorWindowResult result;
|
||||||
|
string? customPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||||
|
customPath = errorWindow.GetCustomHostPath();
|
||||||
|
Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed while waiting for host-not-found window result.", ex);
|
||||||
|
result = ErrorWindowResult.Exit;
|
||||||
|
customPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (errorWindow.IsVisible && errorWindow.IsLoaded)
|
||||||
|
{
|
||||||
|
errorWindow.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close host-not-found error window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (result, customPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
|
||||||
|
{
|
||||||
|
MigrationPromptWindow? migrationWindow = null;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
migrationWindow = new MigrationPromptWindow();
|
||||||
|
migrationWindow.SetLegacyInfo(legacyInfo);
|
||||||
|
migrationWindow.Show();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to show migration prompt window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (migrationWindow is null)
|
||||||
|
{
|
||||||
|
return MigrationResult.Skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
MigrationResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed while waiting for migration prompt result.", ex);
|
||||||
|
result = MigrationResult.Skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
|
||||||
|
{
|
||||||
|
migrationWindow.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failed to close migration prompt window.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapStartupStageToSplashStage(StartupStage stage) => stage switch
|
||||||
|
{
|
||||||
|
StartupStage.Initializing => "initializing",
|
||||||
|
StartupStage.LoadingSettings => "settings",
|
||||||
|
StartupStage.LoadingPlugins => "plugins",
|
||||||
|
StartupStage.InitializingUI => "ui",
|
||||||
|
StartupStage.ShellInitialized => "shell",
|
||||||
|
StartupStage.DesktopVisible => "ready",
|
||||||
|
StartupStage.ActivationRedirected => "activation",
|
||||||
|
StartupStage.ActivationFailed => "error",
|
||||||
|
StartupStage.Ready => "ready",
|
||||||
|
_ => "launch"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static LauncherResult BuildResult(
|
||||||
|
bool success,
|
||||||
|
string stage,
|
||||||
|
string code,
|
||||||
|
string message,
|
||||||
|
Dictionary<string, string>? details = null,
|
||||||
|
string? errorMessage = null)
|
||||||
|
{
|
||||||
|
Logger.Info($"Launcher result prepared. Success={success}; Stage='{stage}'; Code='{code}'.");
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = success,
|
||||||
|
Stage = stage,
|
||||||
|
Code = code,
|
||||||
|
Message = message,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
Details = details ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary<string, string> details)
|
||||||
|
{
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = result.Success,
|
||||||
|
Stage = result.Stage,
|
||||||
|
Code = result.Code,
|
||||||
|
Message = result.Message,
|
||||||
|
CurrentVersion = result.CurrentVersion,
|
||||||
|
TargetVersion = result.TargetVersion,
|
||||||
|
RolledBackTo = result.RolledBackTo,
|
||||||
|
Details = MergeDetails(details, result.Details),
|
||||||
|
InstalledPackagePath = result.InstalledPackagePath,
|
||||||
|
ManifestId = result.ManifestId,
|
||||||
|
ManifestName = result.ManifestName,
|
||||||
|
ErrorMessage = result.ErrorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildLauncherContextDetails(
|
||||||
|
CommandContext context,
|
||||||
|
OobeLaunchDecision oobeDecision,
|
||||||
|
string appRoot)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["command"] = context.Command,
|
||||||
|
["launchSource"] = context.LaunchSource,
|
||||||
|
["isGuiMode"] = context.IsGuiCommand.ToString(),
|
||||||
|
["isDebugMode"] = context.IsDebugMode.ToString(),
|
||||||
|
["isElevated"] = oobeDecision.IsElevated.ToString(),
|
||||||
|
["resolvedAppRoot"] = appRoot,
|
||||||
|
["oobeStatePath"] = oobeDecision.StatePath,
|
||||||
|
["oobeStateStatus"] = oobeDecision.Status.ToString(),
|
||||||
|
["oobeDecision"] = oobeDecision.ShouldShowOobe ? "show" : "skip",
|
||||||
|
["oobeSuppressionReason"] = oobeDecision.SuppressionReason,
|
||||||
|
["oobeResultCode"] = oobeDecision.ResultCode,
|
||||||
|
["userSid"] = oobeDecision.UserSid ?? string.Empty,
|
||||||
|
["usedLegacyOobeMarker"] = oobeDecision.UsedLegacyMarker.ToString(),
|
||||||
|
["migratedLegacyOobeMarker"] = oobeDecision.MigratedLegacyMarker.ToString(),
|
||||||
|
["oobeStateError"] = oobeDecision.ErrorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildResolutionDetails(
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
HostStartAttempt? firstAttempt,
|
||||||
|
HostStartAttempt? secondAttempt,
|
||||||
|
string? failureStage)
|
||||||
|
{
|
||||||
|
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["resolvedAppRoot"] = resolution.AppRoot,
|
||||||
|
["explicitAppRoot"] = resolution.ExplicitAppRoot ?? string.Empty,
|
||||||
|
["resolvedHostPath"] = resolution.ResolvedHostPath ?? string.Empty,
|
||||||
|
["resolutionSource"] = resolution.ResolutionSource ?? string.Empty,
|
||||||
|
["devModeConfigIgnored"] = resolution.DevModeConfigIgnored.ToString(),
|
||||||
|
["searchedPaths"] = string.Join(" | ", resolution.SearchedPaths),
|
||||||
|
["failureStage"] = failureStage ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
if (firstAttempt is not null)
|
||||||
|
{
|
||||||
|
details["startMode"] = firstAttempt.StartMode.ToString();
|
||||||
|
details["processCreated"] = firstAttempt.ProcessCreated.ToString();
|
||||||
|
details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty;
|
||||||
|
details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty;
|
||||||
|
details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondAttempt is not null)
|
||||||
|
{
|
||||||
|
details["fallbackStartMode"] = secondAttempt.StartMode.ToString();
|
||||||
|
details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString();
|
||||||
|
details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty;
|
||||||
|
details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty;
|
||||||
|
details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> MergeDetails(
|
||||||
|
Dictionary<string, string> left,
|
||||||
|
Dictionary<string, string> right)
|
||||||
|
{
|
||||||
|
var merged = new Dictionary<string, string>(left, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var pair in right)
|
||||||
|
{
|
||||||
|
merged[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteArgument(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new System.Text.StringBuilder();
|
||||||
|
builder.Append('"');
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
if (ch == '"')
|
||||||
|
{
|
||||||
|
builder.Append("\\\"");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('"');
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureExecutable(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mode = File.GetUnixFileMode(path);
|
||||||
|
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
||||||
|
File.SetUnixFileMode(path, mode);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum HostStartMode
|
||||||
|
{
|
||||||
|
ShellExecute,
|
||||||
|
Direct
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record HostStartAttempt(
|
||||||
|
HostStartMode StartMode,
|
||||||
|
bool ProcessCreated,
|
||||||
|
Process? Process,
|
||||||
|
bool ExitedEarly,
|
||||||
|
int? ExitCode,
|
||||||
|
string? FailureReason)
|
||||||
|
{
|
||||||
|
public int? ProcessId => Process?.Id;
|
||||||
|
|
||||||
|
public static HostStartAttempt Started(HostStartMode startMode, Process process) =>
|
||||||
|
new(startMode, true, process, false, null, null);
|
||||||
|
|
||||||
|
public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode) =>
|
||||||
|
new(startMode, true, process, true, exitCode, null);
|
||||||
|
|
||||||
|
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason) =>
|
||||||
|
new(startMode, false, null, false, null, failureReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record HostLaunchOutcome(
|
||||||
|
LauncherResult Result,
|
||||||
|
Process? Process,
|
||||||
|
LauncherResult? ImmediateResult,
|
||||||
|
Dictionary<string, string> Details)
|
||||||
|
{
|
||||||
|
public static HostLaunchOutcome FromResult(LauncherResult result) =>
|
||||||
|
new(result, null, result.Success ? result : null, result.Details);
|
||||||
|
|
||||||
|
public static HostLaunchOutcome FromImmediateResult(LauncherResult result) =>
|
||||||
|
new(result, null, result, result.Details);
|
||||||
|
|
||||||
|
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
|
||||||
|
new(result, process, null, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
344
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
344
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LegacyVersionDetector
|
||||||
|
{
|
||||||
|
private const string LegacyAppName = "LanMountainDesktop";
|
||||||
|
private const string LegacyExeName = "LanMountainDesktop.exe";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检测是否存在老版本安装
|
||||||
|
/// </summary>
|
||||||
|
public static LegacyVersionInfo? DetectLegacyInstallation()
|
||||||
|
{
|
||||||
|
// 1. 检查注册表(安装版)
|
||||||
|
var registryInfo = DetectFromRegistry();
|
||||||
|
if (registryInfo != null)
|
||||||
|
{
|
||||||
|
return registryInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查常见安装目录
|
||||||
|
var commonPaths = DetectFromCommonPaths();
|
||||||
|
if (commonPaths != null)
|
||||||
|
{
|
||||||
|
return commonPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查便携版位置
|
||||||
|
var portableInfo = DetectPortableInstallation();
|
||||||
|
if (portableInfo != null)
|
||||||
|
{
|
||||||
|
return portableInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从注册表检测安装信息
|
||||||
|
/// </summary>
|
||||||
|
private static LegacyVersionInfo? DetectFromRegistry()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 检查 HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(
|
||||||
|
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
|
||||||
|
|
||||||
|
if (key != null)
|
||||||
|
{
|
||||||
|
var installLocation = key.GetValue("InstallLocation") as string;
|
||||||
|
var displayVersion = key.GetValue("DisplayVersion") as string;
|
||||||
|
var uninstallString = key.GetValue("UninstallString") as string;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(installLocation) &&
|
||||||
|
File.Exists(Path.Combine(installLocation, LegacyExeName)))
|
||||||
|
{
|
||||||
|
return new LegacyVersionInfo
|
||||||
|
{
|
||||||
|
Version = displayVersion ?? "0.8.x",
|
||||||
|
InstallPath = installLocation,
|
||||||
|
UninstallCommand = uninstallString,
|
||||||
|
InstallType = LegacyInstallType.Registry
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 HKCU(用户级安装)
|
||||||
|
using var userKey = Registry.CurrentUser.OpenSubKey(
|
||||||
|
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
|
||||||
|
|
||||||
|
if (userKey != null)
|
||||||
|
{
|
||||||
|
var installLocation = userKey.GetValue("InstallLocation") as string;
|
||||||
|
var displayVersion = userKey.GetValue("DisplayVersion") as string;
|
||||||
|
var uninstallString = userKey.GetValue("UninstallString") as string;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(installLocation) &&
|
||||||
|
File.Exists(Path.Combine(installLocation, LegacyExeName)))
|
||||||
|
{
|
||||||
|
return new LegacyVersionInfo
|
||||||
|
{
|
||||||
|
Version = displayVersion ?? "0.8.x",
|
||||||
|
InstallPath = installLocation,
|
||||||
|
UninstallCommand = uninstallString,
|
||||||
|
InstallType = LegacyInstallType.Registry
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[LegacyVersionDetector] Registry detection failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从常见安装路径检测
|
||||||
|
/// </summary>
|
||||||
|
private static LegacyVersionInfo? DetectFromCommonPaths()
|
||||||
|
{
|
||||||
|
var commonPaths = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), LegacyAppName),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), LegacyAppName),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), LegacyAppName),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), LegacyAppName),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in commonPaths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
// 检查是否存在老版本的特征文件(没有 app-* 目录)
|
||||||
|
var exePath = Path.Combine(path, LegacyExeName);
|
||||||
|
var hasAppDirs = Directory.GetDirectories(path, "app-*").Length > 0;
|
||||||
|
|
||||||
|
if (File.Exists(exePath) && !hasAppDirs)
|
||||||
|
{
|
||||||
|
// 尝试读取版本信息
|
||||||
|
var version = TryGetFileVersion(exePath);
|
||||||
|
|
||||||
|
return new LegacyVersionInfo
|
||||||
|
{
|
||||||
|
Version = version ?? "0.8.x",
|
||||||
|
InstallPath = path,
|
||||||
|
UninstallCommand = FindUninstaller(path),
|
||||||
|
InstallType = LegacyInstallType.CommonPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[LegacyVersionDetector] Path detection failed for {path}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检测便携版安装
|
||||||
|
/// </summary>
|
||||||
|
private static LegacyVersionInfo? DetectPortableInstallation()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 检查启动器所在目录的父目录(便携版常见布局)
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
|
||||||
|
|
||||||
|
if (Directory.Exists(parentDir))
|
||||||
|
{
|
||||||
|
var exePath = Path.Combine(parentDir, LegacyExeName);
|
||||||
|
var hasAppDirs = Directory.GetDirectories(parentDir, "app-*").Length > 0;
|
||||||
|
|
||||||
|
// 如果存在 exe 且没有 app-* 目录,可能是老版本
|
||||||
|
if (File.Exists(exePath) && !hasAppDirs)
|
||||||
|
{
|
||||||
|
var version = TryGetFileVersion(exePath);
|
||||||
|
|
||||||
|
// 检查是否真的是老版本(通过文件版本或特定标记)
|
||||||
|
if (IsLegacyVersion(version))
|
||||||
|
{
|
||||||
|
return new LegacyVersionInfo
|
||||||
|
{
|
||||||
|
Version = version ?? "0.8.x",
|
||||||
|
InstallPath = parentDir,
|
||||||
|
UninstallCommand = null, // 便携版没有卸载程序
|
||||||
|
InstallType = LegacyInstallType.Portable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[LegacyVersionDetector] Portable detection failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找卸载程序
|
||||||
|
/// </summary>
|
||||||
|
private static string? FindUninstaller(string installPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 常见的卸载程序命名
|
||||||
|
var uninstallerNames = new[] { "unins000.exe", "uninstall.exe", "Uninstall.exe" };
|
||||||
|
|
||||||
|
foreach (var name in uninstallerNames)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installPath, name);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取文件版本
|
||||||
|
/// </summary>
|
||||||
|
private static string? TryGetFileVersion(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var versionInfo = FileVersionInfo.GetVersionInfo(filePath);
|
||||||
|
return versionInfo.FileVersion;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否为老版本(版本号 < 1.0.0)
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsLegacyVersion(string? version)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
return true; // 无法确定版本时,保守认为是老版本
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Version.TryParse(version.Split(' ')[0], out var v))
|
||||||
|
{
|
||||||
|
return v.Major < 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开卸载界面
|
||||||
|
/// </summary>
|
||||||
|
public static void OpenUninstallInterface(LegacyVersionInfo info)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(info.UninstallCommand))
|
||||||
|
{
|
||||||
|
// 有卸载命令,直接执行
|
||||||
|
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
|
||||||
|
var fileName = parts[0].Trim('"');
|
||||||
|
var arguments = parts.Length > 1 ? parts[1] : "";
|
||||||
|
Logger.Info(
|
||||||
|
$"Opening legacy uninstall interface with elevation reason 'legacy_uninstall'. " +
|
||||||
|
$"InstallPath='{info.InstallPath}'; Version='{info.Version}'.");
|
||||||
|
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = true,
|
||||||
|
Verb = "runas" // 请求管理员权限
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 没有卸载命令,打开系统卸载面板
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "appwiz.cpl",
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to open uninstall: {ex.Message}");
|
||||||
|
|
||||||
|
// 兜底:打开系统卸载面板
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "appwiz.cpl",
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在资源管理器中显示老版本位置
|
||||||
|
/// </summary>
|
||||||
|
public static void ShowInExplorer(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "explorer.exe",
|
||||||
|
Arguments = $"/select,\"{path}\"",
|
||||||
|
UseShellExecute = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to show in explorer: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 老版本信息
|
||||||
|
/// </summary>
|
||||||
|
public class LegacyVersionInfo
|
||||||
|
{
|
||||||
|
public string Version { get; set; } = "0.8.x";
|
||||||
|
public string InstallPath { get; set; } = "";
|
||||||
|
public string? UninstallCommand { get; set; }
|
||||||
|
public LegacyInstallType InstallType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 老版本安装类型
|
||||||
|
/// </summary>
|
||||||
|
public enum LegacyInstallType
|
||||||
|
{
|
||||||
|
Registry, // 注册表安装版
|
||||||
|
CommonPath, // 常见路径安装
|
||||||
|
Portable // 便携版
|
||||||
|
}
|
||||||
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 简单的日志记录器 - 同时输出到控制台和文件
|
||||||
|
/// </summary>
|
||||||
|
internal static class Logger
|
||||||
|
{
|
||||||
|
private static readonly object _lock = new();
|
||||||
|
private static string? _logFilePath;
|
||||||
|
private static bool _initialized;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化日志记录器
|
||||||
|
/// </summary>
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logDir = GetLogDirectory();
|
||||||
|
if (!string.IsNullOrEmpty(logDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logDir);
|
||||||
|
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||||
|
_logFilePath = Path.Combine(logDir, $"launcher_{timestamp}.log");
|
||||||
|
Console.WriteLine($"[Logger] Log file initialized: {_logFilePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[Logger] Failed to initialize log file: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取日志文件路径
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetLogFilePath()
|
||||||
|
{
|
||||||
|
return _logFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取日志目录
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetLogDirectory()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(appData))
|
||||||
|
{
|
||||||
|
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
return Path.Combine(launcherDir, ".launcher", "logs");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记录信息日志
|
||||||
|
/// </summary>
|
||||||
|
public static void Info(string message)
|
||||||
|
{
|
||||||
|
WriteLog("INFO", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记录警告日志
|
||||||
|
/// </summary>
|
||||||
|
public static void Warn(string message)
|
||||||
|
{
|
||||||
|
WriteLog("WARN", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记录错误日志
|
||||||
|
/// </summary>
|
||||||
|
public static void Error(string message)
|
||||||
|
{
|
||||||
|
WriteLog("ERROR", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记录错误日志(带异常)
|
||||||
|
/// </summary>
|
||||||
|
public static void Error(string message, Exception exception)
|
||||||
|
{
|
||||||
|
WriteLog("ERROR", $"{message}\n{exception}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入日志
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteLog(string level, string message)
|
||||||
|
{
|
||||||
|
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||||
|
var logLine = $"[{timestamp}] [{level}] {message}";
|
||||||
|
|
||||||
|
Console.WriteLine(logLine);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_logFilePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
File.AppendAllText(_logFilePath, logLine + Environment.NewLine, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
221
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class OobeStateService
|
||||||
|
{
|
||||||
|
private const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
private readonly string _stateDirectory;
|
||||||
|
private readonly string _statePath;
|
||||||
|
private readonly string _legacyMarkerPath;
|
||||||
|
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||||
|
|
||||||
|
public OobeStateService(
|
||||||
|
string appRoot,
|
||||||
|
string? stateRootOverride = null,
|
||||||
|
LauncherExecutionSnapshot? executionSnapshot = null)
|
||||||
|
{
|
||||||
|
_ = Path.GetFullPath(appRoot);
|
||||||
|
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||||
|
|
||||||
|
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||||
|
? GetDefaultStateRoot()
|
||||||
|
: Path.GetFullPath(stateRootOverride);
|
||||||
|
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
|
||||||
|
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||||
|
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public OobeLaunchDecision Evaluate(CommandContext context)
|
||||||
|
{
|
||||||
|
var decision = EvaluateCore(context);
|
||||||
|
Logger.Info(
|
||||||
|
$"OOBE decision evaluated. LaunchSource='{decision.LaunchSource}'; Status='{decision.Status}'; " +
|
||||||
|
$"ShouldShow={decision.ShouldShowOobe}; IsElevated={decision.IsElevated}; " +
|
||||||
|
$"StatePath='{decision.StatePath}'; SuppressionReason='{decision.SuppressionReason}'; " +
|
||||||
|
$"ResultCode='{decision.ResultCode}'; UserSid='{decision.UserSid ?? string.Empty}'.");
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OobeCompletionResult MarkCompleted(CommandContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_stateDirectory);
|
||||||
|
var payload = new OobeStateFile
|
||||||
|
{
|
||||||
|
SchemaVersion = CurrentSchemaVersion,
|
||||||
|
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
UserName = _executionSnapshot.UserName,
|
||||||
|
UserSid = _executionSnapshot.UserSid,
|
||||||
|
LaunchSource = context.LaunchSource
|
||||||
|
};
|
||||||
|
|
||||||
|
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
|
||||||
|
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
|
||||||
|
File.WriteAllText(tempPath, json);
|
||||||
|
File.Move(tempPath, _statePath, overwrite: true);
|
||||||
|
TryDeleteLegacyMarker();
|
||||||
|
|
||||||
|
Logger.Info(
|
||||||
|
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||||
|
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
|
||||||
|
|
||||||
|
return new OobeCompletionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ResultCode = "ok"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn(
|
||||||
|
$"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||||
|
$"Error='{ex.Message}'.");
|
||||||
|
return new OobeCompletionResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
ResultCode = "oobe_state_unavailable",
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OobeLaunchDecision EvaluateCore(CommandContext context)
|
||||||
|
{
|
||||||
|
if (string.Equals(context.LaunchSource, "debug-preview", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return BuildSuppressedDecision(context, "debug_preview", "oobe_suppressed_debug_preview");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.IsMaintenanceCommand)
|
||||||
|
{
|
||||||
|
return BuildSuppressedDecision(context, "maintenance", "oobe_suppressed_maintenance");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var migratedLegacyMarker = false;
|
||||||
|
if (File.Exists(_statePath))
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(_statePath);
|
||||||
|
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
|
||||||
|
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
|
||||||
|
{
|
||||||
|
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(_legacyMarkerPath))
|
||||||
|
{
|
||||||
|
migratedLegacyMarker = TryMigrateLegacyMarker(context);
|
||||||
|
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_executionSnapshot.IsElevated)
|
||||||
|
{
|
||||||
|
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BuildUnavailableDecision(context, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryMigrateLegacyMarker(CommandContext context)
|
||||||
|
{
|
||||||
|
var result = MarkCompleted(context);
|
||||||
|
return result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryDeleteLegacyMarker()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_legacyMarkerPath))
|
||||||
|
{
|
||||||
|
File.Delete(_legacyMarkerPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OobeLaunchDecision BuildDecision(
|
||||||
|
CommandContext context,
|
||||||
|
OobeStateStatus status,
|
||||||
|
bool shouldShowOobe,
|
||||||
|
bool usedLegacyMarker = false,
|
||||||
|
bool migratedLegacyMarker = false)
|
||||||
|
{
|
||||||
|
return new OobeLaunchDecision
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
ShouldShowOobe = shouldShowOobe,
|
||||||
|
StatePath = _statePath,
|
||||||
|
LaunchSource = context.LaunchSource,
|
||||||
|
IsElevated = _executionSnapshot.IsElevated,
|
||||||
|
UserName = _executionSnapshot.UserName,
|
||||||
|
UserSid = _executionSnapshot.UserSid,
|
||||||
|
UsedLegacyMarker = usedLegacyMarker,
|
||||||
|
MigratedLegacyMarker = migratedLegacyMarker,
|
||||||
|
ResultCode = "ok"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private OobeLaunchDecision BuildSuppressedDecision(CommandContext context, string reason, string resultCode)
|
||||||
|
{
|
||||||
|
return new OobeLaunchDecision
|
||||||
|
{
|
||||||
|
Status = OobeStateStatus.Suppressed,
|
||||||
|
ShouldShowOobe = false,
|
||||||
|
StatePath = _statePath,
|
||||||
|
LaunchSource = context.LaunchSource,
|
||||||
|
IsElevated = _executionSnapshot.IsElevated,
|
||||||
|
UserName = _executionSnapshot.UserName,
|
||||||
|
UserSid = _executionSnapshot.UserSid,
|
||||||
|
SuppressionReason = reason,
|
||||||
|
ResultCode = resultCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private OobeLaunchDecision BuildUnavailableDecision(CommandContext context, string errorMessage)
|
||||||
|
{
|
||||||
|
return new OobeLaunchDecision
|
||||||
|
{
|
||||||
|
Status = OobeStateStatus.Unavailable,
|
||||||
|
ShouldShowOobe = false,
|
||||||
|
StatePath = _statePath,
|
||||||
|
LaunchSource = context.LaunchSource,
|
||||||
|
IsElevated = _executionSnapshot.IsElevated,
|
||||||
|
UserName = _executionSnapshot.UserName,
|
||||||
|
UserSid = _executionSnapshot.UserSid,
|
||||||
|
ResultCode = "oobe_state_unavailable",
|
||||||
|
ErrorMessage = errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultStateRoot()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (string.IsNullOrWhiteSpace(appData))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(appData, "LanMountainDesktop");
|
||||||
|
}
|
||||||
|
}
|
||||||
271
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
271
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件安装服务 - 简化版,不依赖 PluginSdk
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PluginInstallerService
|
||||||
|
{
|
||||||
|
private const string ManifestFileName = "manifest.json";
|
||||||
|
private const string PackageFileExtension = ".lmdp";
|
||||||
|
private const string RuntimeDirectoryName = "runtime";
|
||||||
|
|
||||||
|
private static readonly TimeSpan[] RetryDelays =
|
||||||
|
[
|
||||||
|
TimeSpan.FromMilliseconds(120),
|
||||||
|
TimeSpan.FromMilliseconds(250),
|
||||||
|
TimeSpan.FromMilliseconds(500)
|
||||||
|
];
|
||||||
|
|
||||||
|
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
||||||
|
{
|
||||||
|
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||||
|
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||||
|
|
||||||
|
if (!File.Exists(fullSourcePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
||||||
|
{
|
||||||
|
return elevationRequiredResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||||
|
Directory.CreateDirectory(fullPluginsDirectory);
|
||||||
|
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||||
|
var stagingPath = destinationPath + ".incoming";
|
||||||
|
DeleteFileWithRetry(stagingPath);
|
||||||
|
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||||
|
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||||
|
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||||
|
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Stage = "plugin.install",
|
||||||
|
Code = "ok",
|
||||||
|
Message = "Plugin installed.",
|
||||||
|
InstalledPackagePath = destinationPath,
|
||||||
|
ManifestId = manifest.Id,
|
||||||
|
ManifestName = manifest.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (string.IsNullOrWhiteSpace(localAppData))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
|
||||||
|
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
|
||||||
|
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Warn(
|
||||||
|
$"Plugin installation requires explicit elevation. Reason='plugin_requires_elevation'; " +
|
||||||
|
$"PluginsDirectory='{pluginsDirectory}'; AllowedRoot='{allowedRoot}'.");
|
||||||
|
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "plugin.install",
|
||||||
|
Code = "plugin_elevation_required",
|
||||||
|
Message = "Plugin installation outside the current user's LanMountainDesktop data directory requires explicit elevation.",
|
||||||
|
ErrorMessage = "Plugin installation target is outside the current user's LanMountainDesktop data directory.",
|
||||||
|
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["pluginsDirectory"] = pluginsDirectory,
|
||||||
|
["allowedRoot"] = allowedRoot,
|
||||||
|
["elevationReason"] = "outside_user_scope"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
|
var entries = archive.Entries
|
||||||
|
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (entries.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Length > 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = entries[0].Open();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var json = reader.ReadToEnd();
|
||||||
|
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
|
||||||
|
if (manifest == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||||
|
{
|
||||||
|
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
|
||||||
|
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
||||||
|
Directory.CreateDirectory(pendingDeletionDir);
|
||||||
|
|
||||||
|
foreach (var existingPackagePath in Directory
|
||||||
|
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||||
|
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CleanupPendingDeletions(pendingDeletionDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DeleteFileWithRetry(existingPackagePath);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(existingPackagePath);
|
||||||
|
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
|
||||||
|
File.Move(existingPackagePath, pendingPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CleanupPendingDeletions(string pendingDeletionDir)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(pendingDeletionDir))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(pendingFile);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||||
|
{
|
||||||
|
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||||
|
{
|
||||||
|
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteFileWithRetry(string filePath)
|
||||||
|
{
|
||||||
|
Retry(() =>
|
||||||
|
{
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Retry(Action action)
|
||||||
|
{
|
||||||
|
Exception? lastException = null;
|
||||||
|
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
if (attempt >= RetryDelays.Length)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(RetryDelays[attempt]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastException is not null)
|
||||||
|
{
|
||||||
|
throw lastException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildInstalledPackageFileName(string pluginId)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
|
return fileName + PackageFileExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureTrailingSeparator(string path)
|
||||||
|
{
|
||||||
|
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? path
|
||||||
|
: path + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 简化的插件清单模型
|
||||||
|
/// </summary>
|
||||||
|
public class PluginManifest
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Version { get; set; } = "";
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Author { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class PluginUpgradeQueueService
|
||||||
|
{
|
||||||
|
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
|
||||||
|
|
||||||
|
private readonly PluginInstallerService _installerService;
|
||||||
|
|
||||||
|
public PluginUpgradeQueueService(PluginInstallerService installerService)
|
||||||
|
{
|
||||||
|
_installerService = installerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||||
|
{
|
||||||
|
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||||
|
if (!File.Exists(pendingPath))
|
||||||
|
{
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Stage = "plugin.update",
|
||||||
|
Code = "noop",
|
||||||
|
Message = "No pending plugin upgrades."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = File.ReadAllText(pendingPath);
|
||||||
|
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
|
||||||
|
var failures = new List<string>();
|
||||||
|
var succeeded = new List<PendingUpgrade>();
|
||||||
|
|
||||||
|
foreach (var item in pending)
|
||||||
|
{
|
||||||
|
if (!item.IsValid())
|
||||||
|
{
|
||||||
|
failures.Add(item.PluginId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
||||||
|
succeeded.Add(item);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
failures.Add(item.PluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = pending
|
||||||
|
.Except(succeeded)
|
||||||
|
.Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (remaining.Count == 0)
|
||||||
|
{
|
||||||
|
File.Delete(pendingPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = failures.Count == 0,
|
||||||
|
Stage = "plugin.update",
|
||||||
|
Code = failures.Count == 0 ? "ok" : "partial_failed",
|
||||||
|
Message = failures.Count == 0
|
||||||
|
? $"Applied {succeeded.Count} pending plugin upgrade(s)."
|
||||||
|
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record PendingUpgrade(
|
||||||
|
string PluginId,
|
||||||
|
string SourcePackagePath,
|
||||||
|
string TargetVersion,
|
||||||
|
DateTimeOffset CreatedAt)
|
||||||
|
{
|
||||||
|
public bool IsValid()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||||
|
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||||
|
File.Exists(SourcePackagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
161
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新检查服务 - 基于 GitHub Release API
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UpdateCheckService
|
||||||
|
{
|
||||||
|
private const string GitHubApiBase = "https://api.github.com";
|
||||||
|
private readonly string _repoOwner;
|
||||||
|
private readonly string _repoName;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public UpdateCheckService(string repoOwner, string repoName)
|
||||||
|
{
|
||||||
|
_repoOwner = repoOwner;
|
||||||
|
_repoName = repoName;
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查更新
|
||||||
|
/// </summary>
|
||||||
|
public async Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||||
|
string currentVersion,
|
||||||
|
UpdateChannel channel,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var releases = await FetchReleasesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 根据频道过滤版本
|
||||||
|
var filteredReleases = channel == UpdateChannel.Stable
|
||||||
|
? releases.Where(r => !r.Prerelease).ToList()
|
||||||
|
: releases;
|
||||||
|
|
||||||
|
// 找到最新版本
|
||||||
|
var latestRelease = filteredReleases
|
||||||
|
.OrderByDescending(r => ParseVersion(r.TagName))
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (latestRelease == null)
|
||||||
|
{
|
||||||
|
return new UpdateCheckResult
|
||||||
|
{
|
||||||
|
HasUpdate = false,
|
||||||
|
CurrentVersion = currentVersion,
|
||||||
|
ErrorMessage = "No releases found"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestVersion = ParseVersionString(latestRelease.TagName);
|
||||||
|
var current = ParseVersion(currentVersion);
|
||||||
|
var latest = ParseVersion(latestVersion);
|
||||||
|
|
||||||
|
return new UpdateCheckResult
|
||||||
|
{
|
||||||
|
HasUpdate = latest > current,
|
||||||
|
LatestVersion = latestVersion,
|
||||||
|
CurrentVersion = currentVersion,
|
||||||
|
Release = latestRelease
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new UpdateCheckResult
|
||||||
|
{
|
||||||
|
HasUpdate = false,
|
||||||
|
CurrentVersion = currentVersion,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有 Release
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
|
||||||
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
|
||||||
|
|
||||||
|
return releases?.Select(r => new ReleaseInfo
|
||||||
|
{
|
||||||
|
TagName = r.TagName ?? "",
|
||||||
|
Name = r.Name ?? "",
|
||||||
|
Prerelease = r.Prerelease,
|
||||||
|
PublishedAt = r.PublishedAt,
|
||||||
|
Body = r.Body,
|
||||||
|
Assets = r.Assets?.Select(a => new ReleaseAsset
|
||||||
|
{
|
||||||
|
Name = a.Name ?? "",
|
||||||
|
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||||
|
Size = a.Size
|
||||||
|
}).ToList() ?? []
|
||||||
|
}).ToList() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
|
||||||
|
/// </summary>
|
||||||
|
private static string ParseVersionString(string tag)
|
||||||
|
{
|
||||||
|
return tag.TrimStart('v', 'V');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析版本号
|
||||||
|
/// </summary>
|
||||||
|
private static Version ParseVersion(string versionString)
|
||||||
|
{
|
||||||
|
var cleaned = ParseVersionString(versionString);
|
||||||
|
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub API 响应模型
|
||||||
|
internal sealed class GitHubRelease
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag_name")]
|
||||||
|
public string? TagName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("prerelease")]
|
||||||
|
public bool Prerelease { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("published_at")]
|
||||||
|
public DateTime PublishedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("body")]
|
||||||
|
public string? Body { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("assets")]
|
||||||
|
public List<GitHubAsset>? Assets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GitHubAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("browser_download_url")]
|
||||||
|
public string? BrowserDownloadUrl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public long Size { get; set; }
|
||||||
|
}
|
||||||
1590
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
1590
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
File diff suppressed because it is too large
Load Diff
50
LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs
Normal file
50
LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class WelcomeOobeStep : IOobeStep
|
||||||
|
{
|
||||||
|
private readonly CommandContext _context;
|
||||||
|
private readonly OobeStateService _oobeStateService;
|
||||||
|
|
||||||
|
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
|
||||||
|
{
|
||||||
|
_oobeStateService = oobeStateService;
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
OobeWindow? window = null;
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
window = new OobeWindow();
|
||||||
|
window.Show();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||||
|
var completion = _oobeStateService.MarkCompleted(_context);
|
||||||
|
if (!completion.Success)
|
||||||
|
{
|
||||||
|
Logger.Warn(
|
||||||
|
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " +
|
||||||
|
$"Error='{completion.ErrorMessage}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (window.IsVisible)
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开发调试窗口 ViewModel
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private bool _isSplashEnabled = true;
|
||||||
|
private bool _isErrorEnabled = true;
|
||||||
|
private bool _isUpdateEnabled = true;
|
||||||
|
private bool _isOobeEnabled = true;
|
||||||
|
private string _statusMessage = "就绪";
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
#region 页面开关
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动画面是否启用实际功能
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSplashEnabled
|
||||||
|
{
|
||||||
|
get => _isSplashEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isSplashEnabled != value)
|
||||||
|
{
|
||||||
|
_isSplashEnabled = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
UpdateStatus($"启动画面: {(value ? "功能模式" : "仅查看")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误页面是否启用实际功能
|
||||||
|
/// </summary>
|
||||||
|
public bool IsErrorEnabled
|
||||||
|
{
|
||||||
|
get => _isErrorEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isErrorEnabled != value)
|
||||||
|
{
|
||||||
|
_isErrorEnabled = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
UpdateStatus($"错误页面: {(value ? "功能模式" : "仅查看")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新页面是否启用实际功能
|
||||||
|
/// </summary>
|
||||||
|
public bool IsUpdateEnabled
|
||||||
|
{
|
||||||
|
get => _isUpdateEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isUpdateEnabled != value)
|
||||||
|
{
|
||||||
|
_isUpdateEnabled = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
UpdateStatus($"更新页面: {(value ? "功能模式" : "仅查看")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OOBE页面是否启用实际功能
|
||||||
|
/// </summary>
|
||||||
|
public bool IsOobeEnabled
|
||||||
|
{
|
||||||
|
get => _isOobeEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isOobeEnabled != value)
|
||||||
|
{
|
||||||
|
_isOobeEnabled = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
UpdateStatus($"OOBE页面: {(value ? "功能模式" : "仅查看")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 状态信息
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态消息
|
||||||
|
/// </summary>
|
||||||
|
public string StatusMessage
|
||||||
|
{
|
||||||
|
get => _statusMessage;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (_statusMessage != value)
|
||||||
|
{
|
||||||
|
_statusMessage = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 命令
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开启动画面命令
|
||||||
|
/// </summary>
|
||||||
|
public ICommand OpenSplashCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开错误页面命令
|
||||||
|
/// </summary>
|
||||||
|
public ICommand OpenErrorCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开更新页面命令
|
||||||
|
/// </summary>
|
||||||
|
public ICommand OpenUpdateCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开OOBE页面命令
|
||||||
|
/// </summary>
|
||||||
|
public ICommand OpenOobeCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全部切换到查看模式命令
|
||||||
|
/// </summary>
|
||||||
|
public ICommand SetAllViewOnlyCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全部切换到功能模式命令
|
||||||
|
/// </summary>
|
||||||
|
public ICommand SetAllFunctionalCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关闭窗口命令
|
||||||
|
/// </summary>
|
||||||
|
public ICommand CloseCommand { get; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 事件
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求打开启动画面
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<SplashOpenEventArgs>? OpenSplashRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求打开错误页面
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<ErrorOpenEventArgs>? OpenErrorRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求打开更新页面
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<UpdateOpenEventArgs>? OpenUpdateRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求打开OOBE页面
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<OobeOpenEventArgs>? OpenOobeRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求关闭窗口
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? CloseRequested;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public DevDebugWindowViewModel()
|
||||||
|
{
|
||||||
|
OpenSplashCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
OpenSplashRequested?.Invoke(this, new SplashOpenEventArgs(IsSplashEnabled));
|
||||||
|
});
|
||||||
|
|
||||||
|
OpenErrorCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
OpenErrorRequested?.Invoke(this, new ErrorOpenEventArgs(IsErrorEnabled));
|
||||||
|
});
|
||||||
|
|
||||||
|
OpenUpdateCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
OpenUpdateRequested?.Invoke(this, new UpdateOpenEventArgs(IsUpdateEnabled));
|
||||||
|
});
|
||||||
|
|
||||||
|
OpenOobeCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
OpenOobeRequested?.Invoke(this, new OobeOpenEventArgs(IsOobeEnabled));
|
||||||
|
});
|
||||||
|
|
||||||
|
SetAllViewOnlyCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
IsSplashEnabled = false;
|
||||||
|
IsErrorEnabled = false;
|
||||||
|
IsUpdateEnabled = false;
|
||||||
|
IsOobeEnabled = false;
|
||||||
|
UpdateStatus("全部页面已切换到查看模式");
|
||||||
|
});
|
||||||
|
|
||||||
|
SetAllFunctionalCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
IsSplashEnabled = true;
|
||||||
|
IsErrorEnabled = true;
|
||||||
|
IsUpdateEnabled = true;
|
||||||
|
IsOobeEnabled = true;
|
||||||
|
UpdateStatus("全部页面已切换到功能模式");
|
||||||
|
});
|
||||||
|
|
||||||
|
CloseCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatus(string message)
|
||||||
|
{
|
||||||
|
StatusMessage = $"[{DateTime.Now:HH:mm:ss}] {message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 事件参数
|
||||||
|
|
||||||
|
public class SplashOpenEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public bool IsFunctional { get; }
|
||||||
|
public SplashOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ErrorOpenEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public bool IsFunctional { get; }
|
||||||
|
public ErrorOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateOpenEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public bool IsFunctional { get; }
|
||||||
|
public UpdateOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OobeOpenEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public bool IsFunctional { get; }
|
||||||
|
public OobeOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
67
LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs
Normal file
67
LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 简单的命令实现
|
||||||
|
/// </summary>
|
||||||
|
public class RelayCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action _execute;
|
||||||
|
private readonly Func<bool>? _canExecute;
|
||||||
|
|
||||||
|
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter)
|
||||||
|
{
|
||||||
|
return _canExecute?.Invoke() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute(object? parameter)
|
||||||
|
{
|
||||||
|
_execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
|
||||||
|
public void RaiseCanExecuteChanged()
|
||||||
|
{
|
||||||
|
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 带参数的 RelayCommand
|
||||||
|
/// </summary>
|
||||||
|
public class RelayCommand<T> : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<T> _execute;
|
||||||
|
private readonly Predicate<T>? _canExecute;
|
||||||
|
|
||||||
|
public RelayCommand(Action<T> execute, Predicate<T>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter)
|
||||||
|
{
|
||||||
|
return _canExecute?.Invoke((T)parameter!) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute(object? parameter)
|
||||||
|
{
|
||||||
|
_execute((T)parameter!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
|
||||||
|
public void RaiseCanExecuteChanged()
|
||||||
|
{
|
||||||
|
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
Normal file
182
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="clr-namespace:LanMountainDesktop.Launcher.ViewModels"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="600"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.DevDebugWindow"
|
||||||
|
x:DataType="vm:DevDebugWindowViewModel"
|
||||||
|
Title="开发调试窗口 - Launcher"
|
||||||
|
Width="500"
|
||||||
|
Height="600"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
|
||||||
|
<Design.DataContext>
|
||||||
|
<vm:DevDebugWindowViewModel />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Border Padding="20"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<StackPanel Grid.Row="0" Margin="0,0,0,20">
|
||||||
|
<TextBlock Text="🛠️ 开发调试窗口"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}" />
|
||||||
|
<TextBlock Text="用于开发和调试 Launcher 的各个页面"
|
||||||
|
FontSize="12"
|
||||||
|
Opacity="0.7"
|
||||||
|
Margin="0,5,0,0"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 页面列表 -->
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="15">
|
||||||
|
|
||||||
|
<!-- 启动画面 -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="15">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="🚀 启动画面 (SplashWindow)"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14" />
|
||||||
|
<TextBlock Text="显示启动进度和状态"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.6"
|
||||||
|
Margin="0,3,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="8">
|
||||||
|
<ToggleSwitch Content="启用功能"
|
||||||
|
IsChecked="{Binding IsSplashEnabled}"
|
||||||
|
OnContent="功能"
|
||||||
|
OffContent="查看" />
|
||||||
|
<Button Content="打开"
|
||||||
|
Command="{Binding OpenSplashCommand}"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 错误页面 -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="15">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="❌ 错误页面 (ErrorWindow)"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14" />
|
||||||
|
<TextBlock Text="显示错误信息和重试选项"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.6"
|
||||||
|
Margin="0,3,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="8">
|
||||||
|
<ToggleSwitch Content="启用功能"
|
||||||
|
IsChecked="{Binding IsErrorEnabled}"
|
||||||
|
OnContent="功能"
|
||||||
|
OffContent="查看" />
|
||||||
|
<Button Content="打开"
|
||||||
|
Command="{Binding OpenErrorCommand}"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 更新页面 -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="15">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="⬆️ 更新页面 (UpdateWindow)"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14" />
|
||||||
|
<TextBlock Text="显示更新进度和状态"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.6"
|
||||||
|
Margin="0,3,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="8">
|
||||||
|
<ToggleSwitch Content="启用功能"
|
||||||
|
IsChecked="{Binding IsUpdateEnabled}"
|
||||||
|
OnContent="功能"
|
||||||
|
OffContent="查看" />
|
||||||
|
<Button Content="打开"
|
||||||
|
Command="{Binding OpenUpdateCommand}"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- OOBE页面 -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="15">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="👋 OOBE页面 (OobeWindow)"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14" />
|
||||||
|
<TextBlock Text="首次运行引导页面"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.6"
|
||||||
|
Margin="0,3,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="8">
|
||||||
|
<ToggleSwitch Content="启用功能"
|
||||||
|
IsChecked="{Binding IsOobeEnabled}"
|
||||||
|
OnContent="功能"
|
||||||
|
OffContent="查看" />
|
||||||
|
<Button Content="打开"
|
||||||
|
Command="{Binding OpenOobeCommand}"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- 批量操作 -->
|
||||||
|
<StackPanel Grid.Row="2"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Spacing="10"
|
||||||
|
Margin="0,15">
|
||||||
|
<Button Content="全部设为查看模式"
|
||||||
|
Command="{Binding SetAllViewOnlyCommand}"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}" />
|
||||||
|
<Button Content="全部设为功能模式"
|
||||||
|
Command="{Binding SetAllFunctionalCommand}"
|
||||||
|
Background="{DynamicResource SystemControlHighlightAccentBrush}"
|
||||||
|
Foreground="White" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 底部状态栏 -->
|
||||||
|
<Border Grid.Row="3"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="10">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding StatusMessage}"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.8"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="关闭"
|
||||||
|
Command="{Binding CloseCommand}"
|
||||||
|
Padding="15,5" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
196
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
Normal file
196
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Launcher.ViewModels;
|
||||||
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开发调试窗口
|
||||||
|
/// </summary>
|
||||||
|
public partial class DevDebugWindow : Window
|
||||||
|
{
|
||||||
|
private readonly DevDebugWindowViewModel _viewModel;
|
||||||
|
|
||||||
|
public DevDebugWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
_viewModel = new DevDebugWindowViewModel();
|
||||||
|
DataContext = _viewModel;
|
||||||
|
|
||||||
|
// 订阅事件
|
||||||
|
_viewModel.OpenSplashRequested += OnOpenSplashRequested;
|
||||||
|
_viewModel.OpenErrorRequested += OnOpenErrorRequested;
|
||||||
|
_viewModel.OpenUpdateRequested += OnOpenUpdateRequested;
|
||||||
|
_viewModel.OpenOobeRequested += OnOpenOobeRequested;
|
||||||
|
_viewModel.CloseRequested += OnCloseRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开启动画面
|
||||||
|
/// </summary>
|
||||||
|
private void OnOpenSplashRequested(object? sender, SplashOpenEventArgs e)
|
||||||
|
{
|
||||||
|
var splashWindow = new SplashWindow();
|
||||||
|
|
||||||
|
if (!e.IsFunctional)
|
||||||
|
{
|
||||||
|
// 查看模式:显示模拟内容
|
||||||
|
splashWindow.SetDebugMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
splashWindow.Show();
|
||||||
|
|
||||||
|
if (e.IsFunctional)
|
||||||
|
{
|
||||||
|
// 功能模式:模拟正常启动流程
|
||||||
|
_ = SimulateSplashProgress(splashWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开错误页面
|
||||||
|
/// </summary>
|
||||||
|
private void OnOpenErrorRequested(object? sender, ErrorOpenEventArgs e)
|
||||||
|
{
|
||||||
|
var errorWindow = new ErrorWindow();
|
||||||
|
|
||||||
|
if (!e.IsFunctional)
|
||||||
|
{
|
||||||
|
// 查看模式:显示模拟错误
|
||||||
|
errorWindow.SetDebugMode(true);
|
||||||
|
errorWindow.SetErrorMessage("[调试模式] 这是一个模拟的错误消息,用于查看错误页面的样式和布局。");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 功能模式:显示真实错误
|
||||||
|
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。\n\n请检查应用安装是否完整。");
|
||||||
|
}
|
||||||
|
|
||||||
|
errorWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开更新页面
|
||||||
|
/// </summary>
|
||||||
|
private void OnOpenUpdateRequested(object? sender, UpdateOpenEventArgs e)
|
||||||
|
{
|
||||||
|
var updateWindow = new UpdateWindow();
|
||||||
|
|
||||||
|
if (!e.IsFunctional)
|
||||||
|
{
|
||||||
|
// 查看模式:显示模拟更新
|
||||||
|
updateWindow.SetDebugMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWindow.Show();
|
||||||
|
|
||||||
|
if (e.IsFunctional)
|
||||||
|
{
|
||||||
|
// 功能模式:模拟更新进度
|
||||||
|
_ = SimulateUpdateProgress(updateWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开OOBE页面
|
||||||
|
/// </summary>
|
||||||
|
private void OnOpenOobeRequested(object? sender, OobeOpenEventArgs e)
|
||||||
|
{
|
||||||
|
var oobeWindow = new OobeWindow();
|
||||||
|
|
||||||
|
if (!e.IsFunctional)
|
||||||
|
{
|
||||||
|
// 查看模式:显示调试标记(通过标题)
|
||||||
|
oobeWindow.Title = "[调试模式] 欢迎使用阑山桌面";
|
||||||
|
}
|
||||||
|
|
||||||
|
oobeWindow.Show();
|
||||||
|
|
||||||
|
if (e.IsFunctional)
|
||||||
|
{
|
||||||
|
// 功能模式:等待用户点击后自动关闭
|
||||||
|
_ = SimulateOobeProgress(oobeWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模拟OOBE流程
|
||||||
|
/// </summary>
|
||||||
|
private async Task SimulateOobeProgress(OobeWindow oobeWindow)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 等待用户点击开始按钮
|
||||||
|
await oobeWindow.WaitForEnterAsync();
|
||||||
|
|
||||||
|
// 用户点击后,窗口会自动关闭(通过OobeWindow内部的动画和关闭逻辑)
|
||||||
|
Console.WriteLine("[DevDebugWindow] OOBE completed by user");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[DevDebugWindow] Error during OOBE simulation: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关闭窗口
|
||||||
|
/// </summary>
|
||||||
|
private void OnCloseRequested(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模拟启动画面进度
|
||||||
|
/// </summary>
|
||||||
|
private async Task SimulateSplashProgress(SplashWindow splashWindow)
|
||||||
|
{
|
||||||
|
var stages = new[] { "初始化", "检查更新", "加载组件", "启动应用" };
|
||||||
|
var reporter = (ISplashStageReporter)splashWindow;
|
||||||
|
|
||||||
|
for (int i = 0; i < stages.Length; i++)
|
||||||
|
{
|
||||||
|
reporter.ReportStage(stages[i], (i + 1) * 25);
|
||||||
|
await Task.Delay(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3秒后关闭
|
||||||
|
await Task.Delay(3000);
|
||||||
|
splashWindow.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模拟更新进度
|
||||||
|
/// </summary>
|
||||||
|
private async Task SimulateUpdateProgress(UpdateWindow updateWindow)
|
||||||
|
{
|
||||||
|
var stages = new[] { "下载", "验证", "安装", "清理" };
|
||||||
|
|
||||||
|
foreach (var stage in stages)
|
||||||
|
{
|
||||||
|
updateWindow.Report(stage, $"正在{stage}...", Array.IndexOf(stages, stage) * 25 + 10);
|
||||||
|
await Task.Delay(800);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWindow.ReportComplete(true, null);
|
||||||
|
|
||||||
|
// 2秒后关闭
|
||||||
|
await Task.Delay(2000);
|
||||||
|
updateWindow.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
// 取消订阅事件
|
||||||
|
_viewModel.OpenSplashRequested -= OnOpenSplashRequested;
|
||||||
|
_viewModel.OpenErrorRequested -= OnOpenErrorRequested;
|
||||||
|
_viewModel.OpenUpdateRequested -= OnOpenUpdateRequested;
|
||||||
|
_viewModel.OpenOobeRequested -= OnOpenOobeRequested;
|
||||||
|
_viewModel.CloseRequested -= OnCloseRequested;
|
||||||
|
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
107
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="420"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
|
||||||
|
x:DataType="views:ErrorDebugWindow"
|
||||||
|
Title="调试模式"
|
||||||
|
Width="420"
|
||||||
|
Height="320"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:ErrorDebugWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
Text="调试设置"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
Margin="0,0,0,16" />
|
||||||
|
|
||||||
|
<!-- 设置内容 -->
|
||||||
|
<StackPanel Grid.Row="1" Spacing="16">
|
||||||
|
<!-- 开发模式开关 -->
|
||||||
|
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="开发模式"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
<TextBlock Text="启用后自动扫描开发目录"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Margin="0,2,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<ToggleSwitch x:Name="DevModeToggle"
|
||||||
|
Grid.Column="1"
|
||||||
|
OnContent="开"
|
||||||
|
OffContent="关" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 应用路径选择 -->
|
||||||
|
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||||
|
Text="应用路径"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
<TextBlock x:Name="PathTextBlock"
|
||||||
|
Grid.Row="1" Grid.Column="0"
|
||||||
|
Text="未选择"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Margin="0,4,12,0" />
|
||||||
|
<Button x:Name="BrowseButton"
|
||||||
|
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
|
||||||
|
Content="浏览..."
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 提示信息 -->
|
||||||
|
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Padding="12,10"
|
||||||
|
IsVisible="True">
|
||||||
|
<TextBlock Text="此功能仅供开发人员使用"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 按钮区域 -->
|
||||||
|
<StackPanel Grid.Row="2"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Spacing="12"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<Button x:Name="CancelButton"
|
||||||
|
Content="取消"
|
||||||
|
Width="80"
|
||||||
|
Height="32" />
|
||||||
|
<Button x:Name="OkButton"
|
||||||
|
Content="确定"
|
||||||
|
Width="80"
|
||||||
|
Height="32"
|
||||||
|
Theme="{DynamicResource AccentButtonTheme}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
172
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
172
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误调试窗口 - 开发人员专用调试设置
|
||||||
|
/// </summary>
|
||||||
|
public partial class ErrorDebugWindow : Window
|
||||||
|
{
|
||||||
|
private string? _selectedHostPath;
|
||||||
|
private bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用了开发模式
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDevModeEnabled { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 选择的主程序路径
|
||||||
|
/// </summary>
|
||||||
|
public string? SelectedHostPath => _selectedHostPath;
|
||||||
|
|
||||||
|
public ErrorDebugWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
// 延迟到窗口加载完成后再初始化组件
|
||||||
|
this.Loaded += OnWindowLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
|
||||||
|
{
|
||||||
|
IsDevModeEnabled = devModeEnabled;
|
||||||
|
_selectedHostPath = initialPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口加载完成事件
|
||||||
|
/// </summary>
|
||||||
|
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isInitialized) return;
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
|
||||||
|
InitializeComponents();
|
||||||
|
|
||||||
|
// 设置初始值(在视觉树准备好后)
|
||||||
|
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||||
|
if (devModeToggle is not null)
|
||||||
|
{
|
||||||
|
devModeToggle.IsChecked = IsDevModeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdatePathDisplay(_selectedHostPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponents()
|
||||||
|
{
|
||||||
|
// 开发模式开关
|
||||||
|
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||||
|
if (devModeToggle is not null)
|
||||||
|
{
|
||||||
|
devModeToggle.IsCheckedChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
|
||||||
|
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
|
||||||
|
};
|
||||||
|
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览按钮
|
||||||
|
var browseButton = this.FindControl<Button>("BrowseButton");
|
||||||
|
if (browseButton is not null)
|
||||||
|
{
|
||||||
|
browseButton.Click += OnBrowseClick;
|
||||||
|
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定按钮
|
||||||
|
var okButton = this.FindControl<Button>("OkButton");
|
||||||
|
if (okButton is not null)
|
||||||
|
{
|
||||||
|
okButton.Click += (s, e) => Close();
|
||||||
|
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消按钮
|
||||||
|
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||||
|
if (cancelButton is not null)
|
||||||
|
{
|
||||||
|
cancelButton.Click += (s, e) =>
|
||||||
|
{
|
||||||
|
// 取消时恢复原始状态
|
||||||
|
IsDevModeEnabled = false;
|
||||||
|
_selectedHostPath = null;
|
||||||
|
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
|
||||||
|
Close();
|
||||||
|
};
|
||||||
|
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 浏览按钮点击
|
||||||
|
/// </summary>
|
||||||
|
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var storageProvider = StorageProvider;
|
||||||
|
if (storageProvider is null) return;
|
||||||
|
|
||||||
|
var options = new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "选择阑山桌面主程序",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[]
|
||||||
|
{
|
||||||
|
new FilePickerFileType("可执行文件")
|
||||||
|
{
|
||||||
|
Patterns = OperatingSystem.IsWindows()
|
||||||
|
? new[] { "*.exe" }
|
||||||
|
: new[] { "*" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||||
|
if (result.Count > 0)
|
||||||
|
{
|
||||||
|
_selectedHostPath = result[0].Path.LocalPath;
|
||||||
|
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
|
||||||
|
UpdatePathDisplay(_selectedHostPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新路径显示
|
||||||
|
/// </summary>
|
||||||
|
private void UpdatePathDisplay(string? path)
|
||||||
|
{
|
||||||
|
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
||||||
|
if (pathTextBlock is not null)
|
||||||
|
{
|
||||||
|
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
105
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="520"
|
||||||
|
d:DesignHeight="280"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
|
||||||
|
x:DataType="views:ErrorWindow"
|
||||||
|
Title="阑山桌面"
|
||||||
|
Width="520"
|
||||||
|
Height="280"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:ErrorWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<!-- Fluent Design 风格对话框布局 -->
|
||||||
|
<Grid RowDefinitions="*,Auto">
|
||||||
|
<!-- 主内容区域:左侧图标 + 右侧文字 -->
|
||||||
|
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- 左侧:错误图标(可点击进入调试模式) -->
|
||||||
|
<Border x:Name="ErrorIconBorder"
|
||||||
|
Grid.Column="0"
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="0,4,16,0"
|
||||||
|
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||||
|
CornerRadius="24"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontSize="24"
|
||||||
|
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||||
|
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 右侧:标题 + 内容 -->
|
||||||
|
<StackPanel Grid.Column="1" Spacing="8">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="启动失败"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<TextBlock x:Name="ErrorMessageText"
|
||||||
|
Text="找不到阑山桌面应用程序。"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
LineHeight="20"/>
|
||||||
|
|
||||||
|
<!-- 建议信息 -->
|
||||||
|
<TextBlock x:Name="SuggestionText"
|
||||||
|
Text="请确保应用程序已正确安装,或尝试重新安装。"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
LineHeight="18"
|
||||||
|
Margin="0,4,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 底部:按钮区域 -->
|
||||||
|
<Border Grid.Row="1"
|
||||||
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
Padding="24,16">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<Button x:Name="OpenLogButton"
|
||||||
|
Grid.Column="0"
|
||||||
|
Content="打开日志"
|
||||||
|
Width="100"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="8">
|
||||||
|
<Button x:Name="ExitButton"
|
||||||
|
Content="退出"
|
||||||
|
Width="80"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"/>
|
||||||
|
<Button x:Name="RetryButton"
|
||||||
|
Content="重试"
|
||||||
|
Width="80"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"
|
||||||
|
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
542
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
542
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
|
||||||
|
/// </summary>
|
||||||
|
public partial class ErrorWindow : Window
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
|
||||||
|
private int _iconClickCount = 0;
|
||||||
|
private const int DebugModeClickThreshold = 5;
|
||||||
|
private bool _isDebugMode = false;
|
||||||
|
private string? _customHostPath;
|
||||||
|
private bool _devModeEnabled;
|
||||||
|
|
||||||
|
public ErrorWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
// 先加载保存的状态
|
||||||
|
_devModeEnabled = LoadDevModeStateInternal();
|
||||||
|
_customHostPath = LoadCustomHostPathInternal();
|
||||||
|
|
||||||
|
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
|
||||||
|
this.Loaded += OnWindowLoaded;
|
||||||
|
this.Opened += OnWindowOpened;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口加载完成事件 - 视觉树已准备好
|
||||||
|
/// </summary>
|
||||||
|
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
|
||||||
|
InitializeComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口打开事件
|
||||||
|
/// </summary>
|
||||||
|
private void OnWindowOpened(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[ErrorWindow] Window opened and visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponents()
|
||||||
|
{
|
||||||
|
Console.WriteLine("[ErrorWindow] Initializing components...");
|
||||||
|
|
||||||
|
// 错误图标点击事件(进入调试模式 - 隐藏功能)
|
||||||
|
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
|
||||||
|
if (errorIconBorder is not null)
|
||||||
|
{
|
||||||
|
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||||
|
Console.WriteLine("[ErrorWindow] ErrorIconBorder event bound successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorWindow] Failed to find ErrorIconBorder!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按钮事件
|
||||||
|
var retryButton = this.FindControl<Button>("RetryButton");
|
||||||
|
var exitButton = this.FindControl<Button>("ExitButton");
|
||||||
|
var openLogButton = this.FindControl<Button>("OpenLogButton");
|
||||||
|
|
||||||
|
if (retryButton is not null)
|
||||||
|
{
|
||||||
|
retryButton.Click += OnRetryClick;
|
||||||
|
Console.WriteLine("[ErrorWindow] RetryButton event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorWindow] Failed to find RetryButton!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitButton is not null)
|
||||||
|
{
|
||||||
|
exitButton.Click += OnExitClick;
|
||||||
|
Console.WriteLine("[ErrorWindow] ExitButton event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openLogButton is not null)
|
||||||
|
{
|
||||||
|
openLogButton.Click += OnOpenLogClick;
|
||||||
|
Console.WriteLine("[ErrorWindow] OpenLogButton event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorWindow] Failed to find OpenLogButton!");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[ErrorWindow] Components initialization completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置错误消息
|
||||||
|
/// </summary>
|
||||||
|
public void SetErrorMessage(string message)
|
||||||
|
{
|
||||||
|
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
|
||||||
|
if (errorText is not null)
|
||||||
|
{
|
||||||
|
errorText.Text = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置调试模式
|
||||||
|
/// </summary>
|
||||||
|
public void SetDebugMode(bool isDebugMode)
|
||||||
|
{
|
||||||
|
_isDebugMode = isDebugMode;
|
||||||
|
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||||
|
if (titleText is not null && isDebugMode)
|
||||||
|
{
|
||||||
|
titleText.Text = "[调试模式] 错误页面";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取用户选择的主程序路径
|
||||||
|
/// </summary>
|
||||||
|
public string? GetCustomHostPath() => _customHostPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用了开发模式
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等待用户选择
|
||||||
|
/// </summary>
|
||||||
|
public Task<ErrorWindowResult> WaitForChoiceAsync()
|
||||||
|
{
|
||||||
|
return _completionSource.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
|
||||||
|
/// </summary>
|
||||||
|
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
_iconClickCount++;
|
||||||
|
|
||||||
|
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
|
||||||
|
{
|
||||||
|
EnterDebugMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 进入调试模式 - 显示调试窗口
|
||||||
|
/// </summary>
|
||||||
|
private async void EnterDebugMode()
|
||||||
|
{
|
||||||
|
_isDebugMode = true;
|
||||||
|
|
||||||
|
// 创建并显示调试窗口
|
||||||
|
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
|
||||||
|
{
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||||
|
};
|
||||||
|
|
||||||
|
// 订阅调试窗口关闭事件
|
||||||
|
debugWindow.Closed += (s, e) =>
|
||||||
|
{
|
||||||
|
// 更新状态
|
||||||
|
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||||
|
_customHostPath = debugWindow.SelectedHostPath;
|
||||||
|
|
||||||
|
// 保存开发模式状态和自定义路径
|
||||||
|
SaveDevModeStateInternal(_devModeEnabled);
|
||||||
|
SaveCustomHostPathInternal(_customHostPath);
|
||||||
|
|
||||||
|
// 如果启用了开发模式且没有选择路径,自动扫描
|
||||||
|
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
|
||||||
|
{
|
||||||
|
ScanDevPaths();
|
||||||
|
// 扫描到路径后也保存
|
||||||
|
if (!string.IsNullOrEmpty(_customHostPath))
|
||||||
|
{
|
||||||
|
SaveCustomHostPathInternal(_customHostPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await debugWindow.ShowDialog(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 扫描开发路径
|
||||||
|
/// </summary>
|
||||||
|
private void ScanDevPaths()
|
||||||
|
{
|
||||||
|
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||||
|
var possiblePaths = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
_customHostPath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取配置存储的基础目录
|
||||||
|
/// </summary>
|
||||||
|
private static string GetConfigBaseDirectory()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 优先使用 LocalApplicationData(用户状态)
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(appData))
|
||||||
|
{
|
||||||
|
var configDir = Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||||
|
return configDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// LocalApplicationData 不可用,回退到 Launcher 所在目录
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退方案:使用 Launcher 所在目录
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
var configDir = Path.Combine(launcherDir, ".launcher");
|
||||||
|
return configDir;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 最后的兜底:使用当前目录
|
||||||
|
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保配置目录存在
|
||||||
|
/// </summary>
|
||||||
|
private static bool EnsureConfigDirectory(string dirPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dirPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dirPath);
|
||||||
|
Console.WriteLine($"[ErrorWindow] Created config directory: {dirPath}");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to create config directory: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存开发模式状态(内部方法)
|
||||||
|
/// </summary>
|
||||||
|
private static void SaveDevModeStateInternal(bool enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configDir = GetConfigBaseDirectory();
|
||||||
|
if (!EnsureConfigDirectory(configDir))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorWindow] Cannot save dev mode: config directory unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||||
|
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||||
|
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to save dev mode state: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载开发模式状态(内部方法)
|
||||||
|
/// </summary>
|
||||||
|
private static bool LoadDevModeStateInternal()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configDir = GetConfigBaseDirectory();
|
||||||
|
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||||
|
|
||||||
|
if (File.Exists(devModeFile))
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(devModeFile).Trim();
|
||||||
|
var enabled = content == "1";
|
||||||
|
Console.WriteLine($"[ErrorWindow] Dev mode state loaded: {enabled}");
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to load dev mode state: {ex.Message}");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存自定义主程序路径(内部方法)
|
||||||
|
/// </summary>
|
||||||
|
private static void SaveCustomHostPathInternal(string? path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configDir = GetConfigBaseDirectory();
|
||||||
|
if (!EnsureConfigDirectory(configDir))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[ErrorWindow] Cannot save custom path: config directory unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||||
|
File.WriteAllText(hostPathFile, path ?? string.Empty);
|
||||||
|
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to save custom host path: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载自定义主程序路径(内部方法)
|
||||||
|
/// </summary>
|
||||||
|
private static string? LoadCustomHostPathInternal()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configDir = GetConfigBaseDirectory();
|
||||||
|
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||||
|
|
||||||
|
if (File.Exists(hostPathFile))
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(hostPathFile).Trim();
|
||||||
|
// 验证路径是否仍然有效
|
||||||
|
if (!string.IsNullOrEmpty(content) && File.Exists(content))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径已失效,清理配置文件
|
||||||
|
if (!string.IsNullOrEmpty(content))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(hostPathFile);
|
||||||
|
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
|
||||||
|
}
|
||||||
|
catch (Exception clearEx)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查是否启用了开发模式(静态方法,启动时调用)
|
||||||
|
/// </summary>
|
||||||
|
public static bool CheckDevModeEnabled()
|
||||||
|
{
|
||||||
|
return LoadDevModeStateInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetSavedCustomHostPath()
|
||||||
|
{
|
||||||
|
return LoadCustomHostPathInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRetryClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_completionSource.TrySetResult(ErrorWindowResult.Retry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExitClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开日志文件
|
||||||
|
/// </summary>
|
||||||
|
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logFilePath = Logger.GetLogFilePath();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
|
||||||
|
{
|
||||||
|
// 如果没有日志文件,打开日志目录
|
||||||
|
var logDir = Path.GetDirectoryName(logFilePath);
|
||||||
|
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
|
||||||
|
{
|
||||||
|
OpenFolder(logDir);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 尝试打开配置目录
|
||||||
|
var configDir = GetConfigBaseDirectory();
|
||||||
|
if (Directory.Exists(configDir))
|
||||||
|
{
|
||||||
|
OpenFolder(configDir);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[ErrorWindow] No log file or directory available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
|
||||||
|
OpenFile(logFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开文件
|
||||||
|
/// </summary>
|
||||||
|
private static void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "explorer.exe",
|
||||||
|
Arguments = $"\"{filePath}\"",
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
Process.Start("open", filePath);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
Process.Start("xdg-open", filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开文件夹
|
||||||
|
/// </summary>
|
||||||
|
private static void OpenFolder(string folderPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "explorer.exe",
|
||||||
|
Arguments = $"\"{folderPath}\"",
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
Process.Start("open", folderPath);
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
Process.Start("xdg-open", folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误窗口用户选择结果
|
||||||
|
/// </summary>
|
||||||
|
public enum ErrorWindowResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 重试
|
||||||
|
/// </summary>
|
||||||
|
Retry,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退出
|
||||||
|
/// </summary>
|
||||||
|
Exit
|
||||||
|
}
|
||||||
234
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal file
234
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="600"
|
||||||
|
d:DesignHeight="500"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
|
||||||
|
Title="LanMountain Desktop - Loading Details"
|
||||||
|
Width="600"
|
||||||
|
Height="500"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
CanResize="True"
|
||||||
|
MinWidth="500"
|
||||||
|
MinHeight="400"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
Padding="20,16">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Starting LanMountain Desktop"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||||
|
<TextBlock x:Name="SubtitleText"
|
||||||
|
Text="Initializing..."
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Border Grid.Column="1"
|
||||||
|
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="12,6"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="PercentText"
|
||||||
|
Text="0%"
|
||||||
|
FontSize="16"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="White"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="16,12">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ProgressBar x:Name="OverallProgressBar"
|
||||||
|
Grid.Row="0"
|
||||||
|
Height="8"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
Value="0"
|
||||||
|
CornerRadius="4"
|
||||||
|
Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<Border Grid.Row="1"
|
||||||
|
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="16,12"
|
||||||
|
Margin="0,0,0,12">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
|
||||||
|
Width="40"
|
||||||
|
Height="40"
|
||||||
|
CornerRadius="20"
|
||||||
|
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="CurrentItemIcon"
|
||||||
|
Text=""
|
||||||
|
FontSize="20"
|
||||||
|
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||||
|
Foreground="White"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="CurrentItemName"
|
||||||
|
Grid.Row="0" Grid.Column="1"
|
||||||
|
Text="Initializing..."
|
||||||
|
FontSize="15"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||||
|
|
||||||
|
<TextBlock x:Name="CurrentItemDescription"
|
||||||
|
Grid.Row="1" Grid.Column="1"
|
||||||
|
Text="Preparing components"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Margin="0,4,0,0"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
|
||||||
|
<ProgressBar x:Name="CurrentItemProgress"
|
||||||
|
Height="4"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
Value="0"
|
||||||
|
CornerRadius="2"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Row="2"
|
||||||
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
CornerRadius="8">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="Loading Items"
|
||||||
|
FontSize="12"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||||
|
<TextBlock x:Name="CompletedCountText"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="0"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Margin="0,0,4,0"/>
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="Done"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
Margin="8,0,8,8">
|
||||||
|
<ItemsControl x:Name="LoadingItemsList">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="views:LoadingItemViewModel">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||||
|
Margin="4,3"
|
||||||
|
Opacity="{Binding Opacity}">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding StatusIcon}"
|
||||||
|
FontSize="14"
|
||||||
|
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||||
|
Foreground="{Binding StatusColor}"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="{Binding ProgressText}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Margin="8,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<Border Grid.Column="3"
|
||||||
|
Background="{Binding TypeBackground}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="6,2"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding TypeLabel}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{Binding TypeForeground}"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border x:Name="ErrorPanel"
|
||||||
|
Grid.Row="2"
|
||||||
|
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource SystemFillColorCriticalBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12,10"
|
||||||
|
Margin="16,0,16,12"
|
||||||
|
IsVisible="False">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text=""
|
||||||
|
FontSize="16"
|
||||||
|
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||||
|
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="ErrorText"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="An error occurred while loading."
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Row="3"
|
||||||
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock x:Name="VersionText"
|
||||||
|
Grid.Column="0"
|
||||||
|
Text="v1.0.0"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="DetailsButton"
|
||||||
|
Content="Details"
|
||||||
|
Width="90"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"/>
|
||||||
|
<Button x:Name="CancelButton"
|
||||||
|
Content="Cancel"
|
||||||
|
Width="90"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
392
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal file
392
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鍔犺浇璇︽儏绐楀彛 - 鏄剧ず璇︾粏鐨勫姞杞界姸鎬佸拰杩涘害
|
||||||
|
/// </summary>
|
||||||
|
public partial class LoadingDetailsWindow : Window
|
||||||
|
{
|
||||||
|
private readonly ObservableCollection<LoadingItemViewModel> _items = new();
|
||||||
|
private readonly DispatcherTimer _updateTimer;
|
||||||
|
private DateTimeOffset _startTime;
|
||||||
|
|
||||||
|
public LoadingDetailsWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
|
||||||
|
if (itemsList != null)
|
||||||
|
{
|
||||||
|
itemsList.ItemsSource = _items;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(100)
|
||||||
|
};
|
||||||
|
_updateTimer.Tick += OnUpdateTimerTick;
|
||||||
|
|
||||||
|
_startTime = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 绐楀彛鍔犺浇瀹屾垚
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnLoaded(RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnLoaded(e);
|
||||||
|
_updateTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 绐楀彛鍏抽棴
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnClosing(WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
_updateTimer.Stop();
|
||||||
|
base.OnClosing(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鏇存柊鍔犺浇鐘舵€? /// </summary>
|
||||||
|
public void UpdateLoadingState(LoadingStateMessage state)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 鏇存柊鏍囬<E98F8D>鍜屽壇鏍囬<E98F8D>
|
||||||
|
UpdateHeader(state);
|
||||||
|
|
||||||
|
// 鏇存柊鏁翠綋杩涘害
|
||||||
|
UpdateOverallProgress(state);
|
||||||
|
|
||||||
|
UpdateCurrentItem(state);
|
||||||
|
|
||||||
|
// 鏇存柊鍒楄〃
|
||||||
|
UpdateItemsList(state);
|
||||||
|
|
||||||
|
// 鏇存柊閿欒<E996BF>淇℃伅
|
||||||
|
UpdateErrorPanel(state);
|
||||||
|
|
||||||
|
// 鏇存柊瀹屾垚璁℃暟
|
||||||
|
UpdateCompletedCount(state);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LoadingDetailsWindow] Error updating state: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鏇存柊鏍囬<E98F8D>
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateHeader(LoadingStateMessage state)
|
||||||
|
{
|
||||||
|
var subtitleText = this.FindControl<TextBlock>("SubtitleText");
|
||||||
|
if (subtitleText != null)
|
||||||
|
{
|
||||||
|
subtitleText.Text = GetStageDescription(state.Stage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鏇存柊鏁翠綋杩涘害
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateOverallProgress(LoadingStateMessage state)
|
||||||
|
{
|
||||||
|
var progressBar = this.FindControl<ProgressBar>("OverallProgressBar");
|
||||||
|
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||||
|
|
||||||
|
if (progressBar != null)
|
||||||
|
{
|
||||||
|
progressBar.Value = state.OverallProgressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percentText != null)
|
||||||
|
{
|
||||||
|
percentText.Text = $"{state.OverallProgressPercent}%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鏇存柊褰撳墠娲诲姩椤? /// </summary>
|
||||||
|
private void UpdateCurrentItem(LoadingStateMessage state)
|
||||||
|
{
|
||||||
|
var currentItem = state.ActiveItems.FirstOrDefault();
|
||||||
|
if (currentItem == null) return;
|
||||||
|
|
||||||
|
var nameText = this.FindControl<TextBlock>("CurrentItemName");
|
||||||
|
var descText = this.FindControl<TextBlock>("CurrentItemDescription");
|
||||||
|
var progressBar = this.FindControl<ProgressBar>("CurrentItemProgress");
|
||||||
|
var iconText = this.FindControl<TextBlock>("CurrentItemIcon");
|
||||||
|
|
||||||
|
if (nameText != null)
|
||||||
|
{
|
||||||
|
nameText.Text = currentItem.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descText != null)
|
||||||
|
{
|
||||||
|
descText.Text = currentItem.Message ?? GetItemDescription(currentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBar != null)
|
||||||
|
{
|
||||||
|
progressBar.Value = currentItem.ProgressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconText != null)
|
||||||
|
{
|
||||||
|
iconText.Text = GetItemIcon(currentItem.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鏇存柊鍒楄〃
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateItemsList(LoadingStateMessage state)
|
||||||
|
{
|
||||||
|
foreach (var item in state.ActiveItems)
|
||||||
|
{
|
||||||
|
var existing = _items.FirstOrDefault(i => i.Id == item.Id);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.UpdateFrom(item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_items.Add(new LoadingItemViewModel(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绉婚櫎宸插畬鎴愮殑椤癸紙淇濈暀鏈€杩戝畬鎴愮殑5涓<35>級
|
||||||
|
var completedItems = _items.Where(i => i.State == LoadingState.Completed).ToList();
|
||||||
|
if (completedItems.Count > 5)
|
||||||
|
{
|
||||||
|
var itemsToRemove = completedItems.OrderBy(i => i.CompletedTime).Take(completedItems.Count - 5);
|
||||||
|
foreach (var item in itemsToRemove)
|
||||||
|
{
|
||||||
|
_items.Remove(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鎸夌姸鎬佹帓搴忥細杩涜<E69DA9>涓?-> 绛夊緟涓?-> 宸插畬鎴?-> 澶辫触
|
||||||
|
var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
|
||||||
|
_items.Clear();
|
||||||
|
foreach (var item in sortedItems)
|
||||||
|
{
|
||||||
|
_items.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鏇存柊閿欒<E996BF>闈㈡澘
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateErrorPanel(LoadingStateMessage state)
|
||||||
|
{
|
||||||
|
var errorPanel = this.FindControl<Border>("ErrorPanel");
|
||||||
|
var errorText = this.FindControl<TextBlock>("ErrorText");
|
||||||
|
|
||||||
|
if (errorPanel != null)
|
||||||
|
{
|
||||||
|
errorPanel.IsVisible = state.HasErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorText != null && state.ErrorMessages?.Any() == true)
|
||||||
|
{
|
||||||
|
errorText.Text = string.Join("\n", state.ErrorMessages.Take(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鏇存柊瀹屾垚璁℃暟
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateCompletedCount(LoadingStateMessage state)
|
||||||
|
{
|
||||||
|
var countText = this.FindControl<TextBlock>("CompletedCountText");
|
||||||
|
if (countText != null)
|
||||||
|
{
|
||||||
|
countText.Text = state.CompletedCount.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 瀹氭椂鏇存柊
|
||||||
|
/// </summary>
|
||||||
|
private void OnUpdateTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// 鍙<>互鍦ㄨ繖閲屾坊鍔犳椂闂存樉绀虹瓑瀹炴椂鏇存柊
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鑾峰彇闃舵<E99783>鎻忚堪
|
||||||
|
/// </summary>
|
||||||
|
private static string GetStageDescription(StartupStage stage) => stage switch
|
||||||
|
{
|
||||||
|
StartupStage.Initializing => "正在初始化系统...",
|
||||||
|
StartupStage.LoadingSettings => "正在加载设置...",
|
||||||
|
StartupStage.LoadingPlugins => "正在加载插件...",
|
||||||
|
StartupStage.InitializingUI => "正在初始化界面...",
|
||||||
|
StartupStage.ShellInitialized => "桌面外壳已初始化",
|
||||||
|
StartupStage.DesktopVisible => "桌面已经可见",
|
||||||
|
StartupStage.ActivationRedirected => "已激活现有实例",
|
||||||
|
StartupStage.ActivationFailed => "现有实例激活失败",
|
||||||
|
StartupStage.Ready => "加载完成",
|
||||||
|
_ => "正在加载..."
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鑾峰彇椤规弿杩? /// </summary>
|
||||||
|
private static string GetItemDescription(LoadingItem item)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(item.Description))
|
||||||
|
return item.Description;
|
||||||
|
|
||||||
|
return item.Type switch
|
||||||
|
{
|
||||||
|
LoadingItemType.Plugin => "姝e湪鍔犺浇鎻掍欢...",
|
||||||
|
LoadingItemType.Component => "姝e湪鍔犺浇缁勪欢...",
|
||||||
|
LoadingItemType.Resource => "姝e湪鍔犺浇璧勬簮...",
|
||||||
|
LoadingItemType.Data => "姝e湪鍔犺浇鏁版嵁...",
|
||||||
|
LoadingItemType.Network => "姝e湪涓嬭浇...",
|
||||||
|
_ => "姝e湪澶勭悊..."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鑾峰彇椤瑰浘鏍? /// </summary>
|
||||||
|
private static string GetItemIcon(LoadingItemType type) => type switch
|
||||||
|
{
|
||||||
|
LoadingItemType.Plugin => "\uE768",
|
||||||
|
LoadingItemType.Component => "\uE7C4",
|
||||||
|
LoadingItemType.Resource => "\uE7C5",
|
||||||
|
LoadingItemType.Data => "\uE7C6",
|
||||||
|
LoadingItemType.Network => "\uE774",
|
||||||
|
LoadingItemType.Settings => "\uE713",
|
||||||
|
LoadingItemType.System => "\uE7C7",
|
||||||
|
_ => "\uE768"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鑾峰彇鐘舵€佷紭鍏堢骇
|
||||||
|
/// </summary>
|
||||||
|
private static int GetStatePriority(LoadingState state) => state switch
|
||||||
|
{
|
||||||
|
LoadingState.InProgress => 0,
|
||||||
|
LoadingState.Pending => 1,
|
||||||
|
LoadingState.Completed => 2,
|
||||||
|
LoadingState.Failed => 3,
|
||||||
|
LoadingState.Timeout => 4,
|
||||||
|
LoadingState.Cancelled => 5,
|
||||||
|
_ => 6
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鍔犺浇椤硅<E6A4A4>鍥炬ā鍨?/// </summary>
|
||||||
|
public class LoadingItemViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
public string Name { get; private set; }
|
||||||
|
public LoadingItemType Type { get; private set; }
|
||||||
|
public LoadingState State { get; private set; }
|
||||||
|
public int ProgressPercent { get; private set; }
|
||||||
|
public DateTimeOffset? CompletedTime { get; private set; }
|
||||||
|
|
||||||
|
public string StatusIcon => GetStatusIcon(State);
|
||||||
|
public IBrush StatusColor => GetStatusColor(State);
|
||||||
|
public string ProgressText => State == LoadingState.Completed ? "瀹屾垚" : $"{ProgressPercent}%";
|
||||||
|
public string TypeLabel => GetTypeLabel(Type);
|
||||||
|
public IBrush TypeBackground => GetTypeBackground(Type);
|
||||||
|
public IBrush TypeForeground => GetTypeForeground(Type);
|
||||||
|
public double Opacity => State == LoadingState.Completed ? 0.6 : 1.0;
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
public LoadingItemViewModel(LoadingItem item)
|
||||||
|
{
|
||||||
|
Id = item.Id;
|
||||||
|
UpdateFrom(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateFrom(LoadingItem item)
|
||||||
|
{
|
||||||
|
Name = item.Name;
|
||||||
|
Type = item.Type;
|
||||||
|
State = item.State;
|
||||||
|
ProgressPercent = item.ProgressPercent;
|
||||||
|
|
||||||
|
if (State == LoadingState.Completed && !CompletedTime.HasValue)
|
||||||
|
{
|
||||||
|
CompletedTime = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetStatusIcon(LoadingState state) => state switch
|
||||||
|
{
|
||||||
|
LoadingState.Pending => "\uE7C3",
|
||||||
|
LoadingState.InProgress => "\uE768",
|
||||||
|
LoadingState.Completed => "\uE73E",
|
||||||
|
LoadingState.Failed => "\uE783",
|
||||||
|
LoadingState.Timeout => "\uE71A",
|
||||||
|
LoadingState.Cancelled => "\uE711",
|
||||||
|
_ => "\uE7C3"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IBrush GetStatusColor(LoadingState state) => state switch
|
||||||
|
{
|
||||||
|
LoadingState.Pending => new SolidColorBrush(Colors.Gray),
|
||||||
|
LoadingState.InProgress => new SolidColorBrush(Colors.DodgerBlue),
|
||||||
|
LoadingState.Completed => new SolidColorBrush(Colors.Green),
|
||||||
|
LoadingState.Failed => new SolidColorBrush(Colors.Red),
|
||||||
|
LoadingState.Timeout => new SolidColorBrush(Colors.Orange),
|
||||||
|
LoadingState.Cancelled => new SolidColorBrush(Colors.Gray),
|
||||||
|
_ => new SolidColorBrush(Colors.Gray)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetTypeLabel(LoadingItemType type) => type switch
|
||||||
|
{
|
||||||
|
LoadingItemType.Plugin => "鎻掍欢",
|
||||||
|
LoadingItemType.Component => "缁勪欢",
|
||||||
|
LoadingItemType.Resource => "璧勬簮",
|
||||||
|
LoadingItemType.Data => "鏁版嵁",
|
||||||
|
LoadingItemType.Network => "缃戠粶",
|
||||||
|
LoadingItemType.Settings => "璁剧疆",
|
||||||
|
LoadingItemType.System => "绯荤粺",
|
||||||
|
_ => "鍏朵粬"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IBrush GetTypeBackground(LoadingItemType type) => type switch
|
||||||
|
{
|
||||||
|
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#E3F2FD")),
|
||||||
|
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#F3E5F5")),
|
||||||
|
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#E8F5E9")),
|
||||||
|
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#FFF3E0")),
|
||||||
|
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#E0F7FA")),
|
||||||
|
_ => new SolidColorBrush(Color.Parse("#F5F5F5"))
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IBrush GetTypeForeground(LoadingItemType type) => type switch
|
||||||
|
{
|
||||||
|
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#1976D2")),
|
||||||
|
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#7B1FA2")),
|
||||||
|
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#388E3C")),
|
||||||
|
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#F57C00")),
|
||||||
|
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#0097A7")),
|
||||||
|
_ => new SolidColorBrush(Color.Parse("#616161"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal file
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="520"
|
||||||
|
d:DesignHeight="360"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.MigrationPromptWindow"
|
||||||
|
x:DataType="views:MigrationPromptWindow"
|
||||||
|
Title="阑山桌面 - 版本迁移"
|
||||||
|
Width="520"
|
||||||
|
Height="360"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:MigrationPromptWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="*,Auto">
|
||||||
|
<!-- 主内容区域 -->
|
||||||
|
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- 左侧:信息图标 -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="0,4,16,0"
|
||||||
|
Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||||
|
CornerRadius="24"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontSize="24"
|
||||||
|
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||||
|
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 右侧:内容 -->
|
||||||
|
<StackPanel Grid.Column="1" Spacing="12">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<TextBlock Text="检测到旧版本"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<!-- 说明文字 -->
|
||||||
|
<TextBlock x:Name="DescriptionText"
|
||||||
|
Text="检测到您的系统中安装了旧版本的阑山桌面(0.8.4)。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
LineHeight="20"/>
|
||||||
|
|
||||||
|
<!-- 老版本信息卡片 -->
|
||||||
|
<Border Margin="0,8,0,0"
|
||||||
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||||
|
<!-- 版本号 -->
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||||
|
Text="版本:"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||||
|
<TextBlock x:Name="VersionText"
|
||||||
|
Grid.Row="0" Grid.Column="1"
|
||||||
|
Text="0.8.4"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Margin="8,0,0,0"/>
|
||||||
|
|
||||||
|
<!-- 安装路径 -->
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||||
|
Text="位置:"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||||
|
Margin="0,4,0,0"/>
|
||||||
|
<TextBlock x:Name="PathText"
|
||||||
|
Grid.Row="1" Grid.Column="1"
|
||||||
|
Text="C:\Program Files\LanMountainDesktop"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Margin="8,4,0,0"/>
|
||||||
|
|
||||||
|
<!-- 安装类型 -->
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0"
|
||||||
|
Text="类型:"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||||
|
Margin="0,4,0,0"/>
|
||||||
|
<TextBlock x:Name="TypeText"
|
||||||
|
Grid.Row="2" Grid.Column="1"
|
||||||
|
Text="安装版"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Margin="8,4,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 提示信息 -->
|
||||||
|
<TextBlock Text="卸载旧版本不会影响新版本的使用,您的个人数据将保留。"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,4,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 底部:按钮区域 -->
|
||||||
|
<Border Grid.Row="1"
|
||||||
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
Padding="24,16">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<!-- 左侧:查看位置按钮 -->
|
||||||
|
<Button x:Name="ShowLocationButton"
|
||||||
|
Grid.Column="0"
|
||||||
|
Content="查看位置"
|
||||||
|
Width="100"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
|
||||||
|
<!-- 右侧:操作按钮 -->
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="8">
|
||||||
|
<Button x:Name="SkipButton"
|
||||||
|
Content="暂不处理"
|
||||||
|
Width="100"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"/>
|
||||||
|
<Button x:Name="UninstallButton"
|
||||||
|
Content="卸载旧版本"
|
||||||
|
Width="100"
|
||||||
|
Height="32"
|
||||||
|
FontSize="13"
|
||||||
|
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal file
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 迁移提示窗口 - 提示用户卸载旧版本
|
||||||
|
/// </summary>
|
||||||
|
public partial class MigrationPromptWindow : Window
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<MigrationResult> _completionSource = new();
|
||||||
|
private LegacyVersionInfo? _legacyInfo;
|
||||||
|
|
||||||
|
public MigrationPromptWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
InitializeEventHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置老版本信息
|
||||||
|
/// </summary>
|
||||||
|
public void SetLegacyInfo(LegacyVersionInfo info)
|
||||||
|
{
|
||||||
|
_legacyInfo = info;
|
||||||
|
|
||||||
|
// 更新 UI
|
||||||
|
var versionText = this.FindControl<TextBlock>("VersionText");
|
||||||
|
var pathText = this.FindControl<TextBlock>("PathText");
|
||||||
|
var typeText = this.FindControl<TextBlock>("TypeText");
|
||||||
|
var descriptionText = this.FindControl<TextBlock>("DescriptionText");
|
||||||
|
|
||||||
|
if (versionText != null)
|
||||||
|
{
|
||||||
|
versionText.Text = info.Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathText != null)
|
||||||
|
{
|
||||||
|
pathText.Text = info.InstallPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeText != null)
|
||||||
|
{
|
||||||
|
typeText.Text = info.InstallType switch
|
||||||
|
{
|
||||||
|
LegacyInstallType.Registry => "安装版",
|
||||||
|
LegacyInstallType.Portable => "便携版",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptionText != null)
|
||||||
|
{
|
||||||
|
descriptionText.Text = $"检测到您的系统中安装了旧版本的阑山桌面({info.Version})。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化事件处理程序
|
||||||
|
/// </summary>
|
||||||
|
private void InitializeEventHandlers()
|
||||||
|
{
|
||||||
|
var showLocationButton = this.FindControl<Button>("ShowLocationButton");
|
||||||
|
var skipButton = this.FindControl<Button>("SkipButton");
|
||||||
|
var uninstallButton = this.FindControl<Button>("UninstallButton");
|
||||||
|
|
||||||
|
if (showLocationButton != null)
|
||||||
|
{
|
||||||
|
showLocationButton.Click += OnShowLocationClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipButton != null)
|
||||||
|
{
|
||||||
|
skipButton.Click += OnSkipClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uninstallButton != null)
|
||||||
|
{
|
||||||
|
uninstallButton.Click += OnUninstallClick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查看位置按钮点击
|
||||||
|
/// </summary>
|
||||||
|
private void OnShowLocationClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_legacyInfo != null)
|
||||||
|
{
|
||||||
|
LegacyVersionDetector.ShowInExplorer(_legacyInfo.InstallPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 跳过按钮点击
|
||||||
|
/// </summary>
|
||||||
|
private void OnSkipClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_completionSource.TrySetResult(MigrationResult.Skipped);
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 卸载按钮点击
|
||||||
|
/// </summary>
|
||||||
|
private void OnUninstallClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_legacyInfo != null)
|
||||||
|
{
|
||||||
|
LegacyVersionDetector.OpenUninstallInterface(_legacyInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
_completionSource.TrySetResult(MigrationResult.UninstallOpened);
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等待用户选择
|
||||||
|
/// </summary>
|
||||||
|
public Task<MigrationResult> WaitForChoiceAsync()
|
||||||
|
{
|
||||||
|
return _completionSource.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口关闭事件
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnClosing(WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
// 如果还没有完成,标记为跳过
|
||||||
|
if (!_completionSource.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
_completionSource.TrySetResult(MigrationResult.Skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 迁移结果
|
||||||
|
/// </summary>
|
||||||
|
public enum MigrationResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 用户选择跳过
|
||||||
|
/// </summary>
|
||||||
|
Skipped,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已打开卸载界面
|
||||||
|
/// </summary>
|
||||||
|
UninstallOpened
|
||||||
|
}
|
||||||
76
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
76
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="600"
|
||||||
|
d:DesignHeight="500"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||||
|
x:DataType="views:OobeWindow"
|
||||||
|
Title="欢迎使用阑山桌面"
|
||||||
|
Width="600"
|
||||||
|
Height="500"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:OobeWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid x:Name="ContentGrid">
|
||||||
|
<!-- 主内容区域 -->
|
||||||
|
<Grid Margin="48" RowDefinitions="*,Auto">
|
||||||
|
<!-- 中央内容区域 -->
|
||||||
|
<StackPanel Grid.Row="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Spacing="24">
|
||||||
|
|
||||||
|
<!-- 顶部:完成状态勾号图标 -->
|
||||||
|
<Border Width="80"
|
||||||
|
Height="80"
|
||||||
|
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||||
|
CornerRadius="40"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
|
<ui:SymbolIcon Symbol="Accept"
|
||||||
|
FontSize="40"
|
||||||
|
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 中央:欢迎文字 -->
|
||||||
|
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="欢迎使用阑山桌面"
|
||||||
|
FontSize="28"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Text="你的桌面,不止一面"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 底部:圆形开始按钮 -->
|
||||||
|
<Button Grid.Row="1"
|
||||||
|
x:Name="EnterButton"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Width="56"
|
||||||
|
Height="56"
|
||||||
|
Margin="0,0,0,16"
|
||||||
|
Theme="{DynamicResource AccentButtonTheme}"
|
||||||
|
CornerRadius="28">
|
||||||
|
<ui:SymbolIcon Symbol="Forward"
|
||||||
|
FontSize="24"
|
||||||
|
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
197
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
197
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Animation;
|
||||||
|
using Avalonia.Animation.Easings;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OOBE(首次使用体验)窗口 - 欢迎页面
|
||||||
|
/// </summary>
|
||||||
|
public partial class OobeWindow : Window
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||||
|
private bool _isTransitioning = false;
|
||||||
|
|
||||||
|
public OobeWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
// 延迟到窗口加载完成后再初始化
|
||||||
|
this.Loaded += OnWindowLoaded;
|
||||||
|
this.Opened += OnWindowOpened;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口加载完成事件
|
||||||
|
/// </summary>
|
||||||
|
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
|
||||||
|
|
||||||
|
var enterButton = this.FindControl<Button>("EnterButton");
|
||||||
|
if (enterButton is not null)
|
||||||
|
{
|
||||||
|
enterButton.Click += OnEnterClick;
|
||||||
|
Console.WriteLine("[OobeWindow] EnterButton event bound successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[OobeWindow] Failed to find EnterButton!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口打开事件 - 播放入场动画
|
||||||
|
/// </summary>
|
||||||
|
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
|
||||||
|
await PlayEntranceAnimationAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 播放入场动画
|
||||||
|
/// </summary>
|
||||||
|
private async Task PlayEntranceAnimationAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 获取内容元素
|
||||||
|
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||||
|
if (contentGrid is null)
|
||||||
|
{
|
||||||
|
// 如果没有命名网格,直接返回
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建淡入动画
|
||||||
|
var fadeInAnimation = new Animation
|
||||||
|
{
|
||||||
|
Duration = TimeSpan.FromMilliseconds(600),
|
||||||
|
Easing = new CubicEaseOut(),
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new KeyFrame
|
||||||
|
{
|
||||||
|
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||||
|
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||||
|
},
|
||||||
|
new KeyFrame
|
||||||
|
{
|
||||||
|
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||||
|
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建向上滑动动画
|
||||||
|
var slideUpAnimation = new Animation
|
||||||
|
{
|
||||||
|
Duration = TimeSpan.FromMilliseconds(600),
|
||||||
|
Easing = new CubicEaseOut(),
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new KeyFrame
|
||||||
|
{
|
||||||
|
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
|
||||||
|
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||||
|
},
|
||||||
|
new KeyFrame
|
||||||
|
{
|
||||||
|
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
|
||||||
|
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 应用动画
|
||||||
|
await fadeInAnimation.RunAsync(contentGrid);
|
||||||
|
await slideUpAnimation.RunAsync(contentGrid);
|
||||||
|
|
||||||
|
Console.WriteLine("[OobeWindow] Entrance animation completed");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等待用户点击开始按钮
|
||||||
|
/// </summary>
|
||||||
|
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 进入按钮点击事件
|
||||||
|
/// </summary>
|
||||||
|
private async void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isTransitioning) return;
|
||||||
|
_isTransitioning = true;
|
||||||
|
|
||||||
|
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 播放退出动画
|
||||||
|
await PlayExitAnimationAsync();
|
||||||
|
|
||||||
|
// 完成 OOBE
|
||||||
|
_completionSource.TrySetResult(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
|
||||||
|
_completionSource.TrySetResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 播放退出动画
|
||||||
|
/// </summary>
|
||||||
|
private async Task PlayExitAnimationAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||||
|
if (contentGrid is null)
|
||||||
|
{
|
||||||
|
// 如果没有命名网格,直接延迟后返回
|
||||||
|
await Task.Delay(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建淡出动画
|
||||||
|
var fadeOutAnimation = new Animation
|
||||||
|
{
|
||||||
|
Duration = TimeSpan.FromMilliseconds(200),
|
||||||
|
Easing = new CubicEaseIn(),
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new KeyFrame
|
||||||
|
{
|
||||||
|
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||||
|
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||||
|
},
|
||||||
|
new KeyFrame
|
||||||
|
{
|
||||||
|
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||||
|
KeyTime = TimeSpan.FromMilliseconds(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await fadeOutAnimation.RunAsync(contentGrid);
|
||||||
|
Console.WriteLine("[OobeWindow] Exit animation completed");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
87
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="480"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||||
|
x:DataType="views:SplashWindow"
|
||||||
|
Title="LanMountain Desktop"
|
||||||
|
Width="480"
|
||||||
|
Height="320"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
SystemDecorations="None"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:SplashWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<!-- 左上角:应用名称 -->
|
||||||
|
<TextBlock x:Name="AppNameText"
|
||||||
|
Text="LanMountain Desktop"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="24,24,0,0"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
|
||||||
|
<!-- 底部区域:进度条和状态 -->
|
||||||
|
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- 第一行:左下角版本信息,右下角阶段文字 -->
|
||||||
|
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) -->
|
||||||
|
<Border x:Name="VersionTextBorder"
|
||||||
|
Grid.Column="0"
|
||||||
|
Background="Transparent"
|
||||||
|
Cursor="Hand"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Bottom">
|
||||||
|
<TextBlock x:Name="VersionText"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Opacity="0.8"
|
||||||
|
Text="1.0.0 (Administrate)" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 右下角:阶段文字 -->
|
||||||
|
<TextBlock x:Name="StatusText"
|
||||||
|
Grid.Column="1"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Opacity="0.8"
|
||||||
|
Text="Initializing..." />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 底部:进度条 -->
|
||||||
|
<ProgressBar x:Name="ProgressIndicator"
|
||||||
|
Grid.Row="1"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
Value="0"
|
||||||
|
Height="4"
|
||||||
|
IsIndeterminate="False"
|
||||||
|
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
250
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
250
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动画面窗口 - 简洁设计
|
||||||
|
/// </summary>
|
||||||
|
public partial class SplashWindow : Window, ISplashStageReporter
|
||||||
|
{
|
||||||
|
private int _versionTextClickCount = 0;
|
||||||
|
private const int DebugModeClickThreshold = 5;
|
||||||
|
private bool _isDebugModeOpened = false;
|
||||||
|
|
||||||
|
public SplashWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
// 延迟到窗口加载完成后再绑定事件
|
||||||
|
this.Loaded += OnWindowLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口加载完成事件
|
||||||
|
/// </summary>
|
||||||
|
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
|
||||||
|
|
||||||
|
// 绑定版本文本点击事件(隐藏功能:点击5次打开开发者界面)
|
||||||
|
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
|
||||||
|
if (versionTextBorder is not null)
|
||||||
|
{
|
||||||
|
versionTextBorder.PointerPressed += OnVersionTextClick;
|
||||||
|
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 版本文本点击事件 - 连续点击5次打开开发者界面(隐藏功能)
|
||||||
|
/// </summary>
|
||||||
|
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isDebugModeOpened) return;
|
||||||
|
|
||||||
|
_versionTextClickCount++;
|
||||||
|
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
|
||||||
|
|
||||||
|
if (_versionTextClickCount >= DebugModeClickThreshold)
|
||||||
|
{
|
||||||
|
OpenDebugWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开开发者调试窗口
|
||||||
|
/// </summary>
|
||||||
|
private async void OpenDebugWindow()
|
||||||
|
{
|
||||||
|
_isDebugModeOpened = true;
|
||||||
|
Console.WriteLine("[SplashWindow] Opening debug window...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 加载保存的状态
|
||||||
|
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
|
||||||
|
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
|
||||||
|
|
||||||
|
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
|
||||||
|
{
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||||
|
};
|
||||||
|
|
||||||
|
// 订阅窗口关闭事件以保存状态
|
||||||
|
debugWindow.Closed += (s, e) =>
|
||||||
|
{
|
||||||
|
Console.WriteLine("[SplashWindow] Debug window closed");
|
||||||
|
_isDebugModeOpened = false;
|
||||||
|
_versionTextClickCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
await debugWindow.ShowDialog(this);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
|
||||||
|
_isDebugModeOpened = false;
|
||||||
|
_versionTextClickCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新进度和状态
|
||||||
|
/// </summary>
|
||||||
|
public void Report(string stage, string message)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||||
|
|
||||||
|
if (statusText is null || progressIndicator is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SplashWindow] Controls not found: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态文本
|
||||||
|
statusText.Text = message;
|
||||||
|
|
||||||
|
// 根据阶段更新进度
|
||||||
|
var progress = ResolveProgress(stage);
|
||||||
|
if (progress > 0)
|
||||||
|
{
|
||||||
|
progressIndicator.IsIndeterminate = false;
|
||||||
|
progressIndicator.Value = progress;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
progressIndicator.IsIndeterminate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新进度(0-100)
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateProgress(int percent, string? message = null)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||||
|
|
||||||
|
if (statusText is null || progressIndicator is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
statusText.Text = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressIndicator.IsIndeterminate = false;
|
||||||
|
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新状态文本
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateStatus(string message)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
if (statusText is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusText.Text = message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报告阶段和进度(0-100)
|
||||||
|
/// </summary>
|
||||||
|
public void ReportStage(string stage, int progress)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||||
|
|
||||||
|
if (statusText is null || progressIndicator is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText.Text = stage;
|
||||||
|
progressIndicator.IsIndeterminate = false;
|
||||||
|
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置版本和开发代号
|
||||||
|
/// </summary>
|
||||||
|
public void SetVersionInfo(string version, string codename)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var versionText = this.FindControl<TextBlock>("VersionText");
|
||||||
|
if (versionText is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
versionText.Text = $"{version} ({codename})";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置调试模式
|
||||||
|
/// </summary>
|
||||||
|
public void SetDebugMode(bool isDebugMode)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
if (statusText is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDebugMode)
|
||||||
|
{
|
||||||
|
statusText.Text = "[Debug Mode] Splash Preview";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据阶段名称解析进度值
|
||||||
|
/// </summary>
|
||||||
|
private static int ResolveProgress(string stage)
|
||||||
|
{
|
||||||
|
return stage.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"initializing" => 10,
|
||||||
|
"update" => 30,
|
||||||
|
"plugins" => 50,
|
||||||
|
"launch" => 70,
|
||||||
|
"ready" => 100,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
108
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml
Normal file
108
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="480"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
|
||||||
|
x:DataType="views:UpdateWindow"
|
||||||
|
Title="阑山桌面 - 更新"
|
||||||
|
Width="480"
|
||||||
|
Height="320"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
SystemDecorations="None"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None"
|
||||||
|
Icon="/Assets/logo.ico">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:UpdateWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<!-- 顶部:应用名称和最小化按钮 -->
|
||||||
|
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="阑山桌面"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="6,2"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Update"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 最小化按钮 -->
|
||||||
|
<Button x:Name="MinimizeButton"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontSize="12"
|
||||||
|
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 底部区域:进度条和状态 -->
|
||||||
|
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- 第一行:左下角状态,右下角百分比 -->
|
||||||
|
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- 左下角:状态文字 -->
|
||||||
|
<TextBlock x:Name="StatusText"
|
||||||
|
Grid.Column="0"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Opacity="0.8"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Text="正在更新,请稍候..." />
|
||||||
|
|
||||||
|
<!-- 右下角:百分比 -->
|
||||||
|
<TextBlock x:Name="PercentText"
|
||||||
|
Grid.Column="1"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Opacity="0.8"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Text="0%" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 底部:进度条 -->
|
||||||
|
<ProgressBar x:Name="ProgressIndicator"
|
||||||
|
Grid.Row="1"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
Value="0"
|
||||||
|
Height="4"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
123
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs
Normal file
123
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度
|
||||||
|
/// </summary>
|
||||||
|
public partial class UpdateWindow : Window
|
||||||
|
{
|
||||||
|
public UpdateWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
InitializeEventHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化事件处理程序
|
||||||
|
/// </summary>
|
||||||
|
private void InitializeEventHandlers()
|
||||||
|
{
|
||||||
|
var minimizeButton = this.FindControl<Button>("MinimizeButton");
|
||||||
|
if (minimizeButton != null)
|
||||||
|
{
|
||||||
|
minimizeButton.Click += (s, e) =>
|
||||||
|
{
|
||||||
|
this.WindowState = WindowState.Minimized;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新状态和进度
|
||||||
|
/// </summary>
|
||||||
|
public void Report(string stage, string message, int progressPercent = -1)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||||
|
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||||
|
|
||||||
|
if (statusText is null || progressIndicator is null || percentText is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, PercentText={percentText != null}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText.Text = message;
|
||||||
|
|
||||||
|
if (progressPercent >= 0)
|
||||||
|
{
|
||||||
|
progressIndicator.IsIndeterminate = false;
|
||||||
|
progressIndicator.Value = progressPercent;
|
||||||
|
percentText.Text = $"{progressPercent}%";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
progressIndicator.IsIndeterminate = true;
|
||||||
|
percentText.Text = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 显示更新完成状态
|
||||||
|
/// </summary>
|
||||||
|
public void ReportComplete(bool success, string? errorMessage = null)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||||
|
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||||
|
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||||
|
|
||||||
|
if (statusText is null || progressIndicator is null || percentText is null || titleText is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressIndicator.IsIndeterminate = false;
|
||||||
|
progressIndicator.Value = 100;
|
||||||
|
percentText.Text = "100%";
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
statusText.Text = "更新完成";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
titleText.Text = "更新失败";
|
||||||
|
statusText.Text = errorMessage ?? "更新过程中发生错误";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置调试模式
|
||||||
|
/// </summary>
|
||||||
|
public void SetDebugMode(bool isDebugMode)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||||
|
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||||
|
|
||||||
|
if (statusText is null || titleText is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[UpdateWindow] Controls not found in SetDebugMode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDebugMode)
|
||||||
|
{
|
||||||
|
titleText.Text = "[调试模式] 更新页面";
|
||||||
|
statusText.Text = "预览更新进度界面";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
LanMountainDesktop.Launcher/app.manifest
Normal file
25
LanMountainDesktop.Launcher/app.manifest
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<!-- 明确指定不需要管理员权限 -->
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10/11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
<!-- Windows 8.1 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||||
|
<!-- Windows 8 -->
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||||
|
<!-- Windows 7 -->
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public sealed record PluginAppearanceSnapshotRequest(string SessionId);
|
||||||
|
|
||||||
|
public sealed record PluginAppearanceSnapshot(
|
||||||
|
string ThemeVariant,
|
||||||
|
string? AccentColor = null,
|
||||||
|
double CornerRadiusScale = 1.0,
|
||||||
|
IReadOnlyDictionary<string, double>? CornerRadiusTokens = null,
|
||||||
|
IReadOnlyDictionary<string, string>? ResourceAliases = null);
|
||||||
|
|
||||||
|
public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public sealed record PluginHeartbeatPing(
|
||||||
|
string SessionId,
|
||||||
|
DateTimeOffset SentAtUtc);
|
||||||
|
|
||||||
|
public sealed record PluginHeartbeatPong(
|
||||||
|
string SessionId,
|
||||||
|
DateTimeOffset ReceivedAtUtc);
|
||||||
|
|
||||||
|
public sealed record PluginLogEntry(
|
||||||
|
string Level,
|
||||||
|
string Category,
|
||||||
|
string Message,
|
||||||
|
DateTimeOffset TimestampUtc,
|
||||||
|
string? Exception = null);
|
||||||
|
|
||||||
|
public static class PluginLogLevels
|
||||||
|
{
|
||||||
|
public const string Trace = "trace";
|
||||||
|
public const string Debug = "debug";
|
||||||
|
public const string Information = "information";
|
||||||
|
public const string Warning = "warning";
|
||||||
|
public const string Error = "error";
|
||||||
|
public const string Critical = "critical";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginFaultReport(
|
||||||
|
string SessionId,
|
||||||
|
string FaultKind,
|
||||||
|
bool IsFatal,
|
||||||
|
string Message,
|
||||||
|
string? StackTrace = null,
|
||||||
|
int? WorkerProcessId = null,
|
||||||
|
int? ExitCode = null,
|
||||||
|
DateTimeOffset? OccurredAtUtc = null);
|
||||||
|
|
||||||
|
public static class PluginFaultKinds
|
||||||
|
{
|
||||||
|
public const string ManagedException = "managed-exception";
|
||||||
|
public const string NativeCrash = "native-crash";
|
||||||
|
public const string WatchdogTimeout = "watchdog-timeout";
|
||||||
|
public const string StartupFailure = "startup-failure";
|
||||||
|
public const string ForcedTermination = "forced-termination";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<PackageId>LanMountainDesktop.PluginIsolation.Contracts</PackageId>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<Authors>LanMountainDesktop</Authors>
|
||||||
|
<Description>Transport-neutral IPC contracts for the LanMountainDesktop plugin isolation architecture.</Description>
|
||||||
|
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;Contracts</PackageTags>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public sealed record PluginInitializeRequest(
|
||||||
|
string PluginId,
|
||||||
|
string SessionId,
|
||||||
|
string HostPipeName,
|
||||||
|
string DataDirectory,
|
||||||
|
IReadOnlyDictionary<string, string>? StartupProperties = null);
|
||||||
|
|
||||||
|
public sealed record PluginInitializeResponse(
|
||||||
|
bool Succeeded,
|
||||||
|
string? ErrorCode = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed record PluginStopRequest(
|
||||||
|
string Reason,
|
||||||
|
bool RestartRequested = false);
|
||||||
|
|
||||||
|
public sealed record PluginRestartRequest(string Reason);
|
||||||
|
|
||||||
|
public sealed record PluginLifecycleStateChanged(
|
||||||
|
string State,
|
||||||
|
string? Detail = null);
|
||||||
|
|
||||||
|
public static class PluginLifecycleStates
|
||||||
|
{
|
||||||
|
public const string Starting = "starting";
|
||||||
|
public const string Ready = "ready";
|
||||||
|
public const string Degraded = "degraded";
|
||||||
|
public const string Stopping = "stopping";
|
||||||
|
public const string Stopped = "stopped";
|
||||||
|
public const string Faulted = "faulted";
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public sealed record PluginCapabilityDeclaration(
|
||||||
|
string Name,
|
||||||
|
string Version,
|
||||||
|
string? Description = null);
|
||||||
|
|
||||||
|
public static class PluginCapabilityNames
|
||||||
|
{
|
||||||
|
public const string Settings = "settings";
|
||||||
|
public const string Appearance = "appearance";
|
||||||
|
public const string DesktopComponentUi = "ui.desktop-component";
|
||||||
|
public const string ComponentEditorUi = "ui.component-editor";
|
||||||
|
public const string SettingsPageUi = "ui.settings-page";
|
||||||
|
public const string Logging = "diagnostics.log";
|
||||||
|
public const string FaultReporting = "diagnostics.fault";
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public static class PluginIpcErrorCodes
|
||||||
|
{
|
||||||
|
public const string ProtocolMismatch = "protocol_mismatch";
|
||||||
|
public const string SessionRejected = "session_rejected";
|
||||||
|
public const string CapabilityDenied = "capability_denied";
|
||||||
|
public const string InvalidRequest = "invalid_request";
|
||||||
|
public const string UnsupportedRoute = "unsupported_route";
|
||||||
|
public const string SettingsConflict = "settings_conflict";
|
||||||
|
public const string UiAttachRejected = "ui_attach_rejected";
|
||||||
|
public const string WorkerFaulted = "worker_faulted";
|
||||||
|
public const string WorkerExited = "worker_exited";
|
||||||
|
public const string HeartbeatTimeout = "heartbeat_timeout";
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public static class PluginIpcRoutes
|
||||||
|
{
|
||||||
|
public static class Session
|
||||||
|
{
|
||||||
|
public const string Handshake = "session/handshake";
|
||||||
|
public const string Capabilities = "session/capabilities";
|
||||||
|
public const string Ready = "session/ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Lifecycle
|
||||||
|
{
|
||||||
|
public const string Initialize = "lifecycle/initialize";
|
||||||
|
public const string Stop = "lifecycle/stop";
|
||||||
|
public const string RestartRequest = "lifecycle/restart-request";
|
||||||
|
public const string StateChanged = "lifecycle/state-changed";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Settings
|
||||||
|
{
|
||||||
|
public const string GetSnapshot = "settings/get-snapshot";
|
||||||
|
public const string Write = "settings/write";
|
||||||
|
public const string Changed = "settings/changed";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Appearance
|
||||||
|
{
|
||||||
|
public const string GetSnapshot = "appearance/get-snapshot";
|
||||||
|
public const string Changed = "appearance/changed";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Ui
|
||||||
|
{
|
||||||
|
public const string Attach = "ui/attach";
|
||||||
|
public const string Detach = "ui/detach";
|
||||||
|
public const string Command = "ui/command";
|
||||||
|
public const string StateChanged = "ui/state-changed";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Heartbeat
|
||||||
|
{
|
||||||
|
public const string Ping = "heartbeat/ping";
|
||||||
|
public const string Pong = "heartbeat/pong";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Log
|
||||||
|
{
|
||||||
|
public const string Write = "log/write";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Fault
|
||||||
|
{
|
||||||
|
public const string Report = "fault/report";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
[JsonSerializable(typeof(PluginCapabilityDeclaration))]
|
||||||
|
[JsonSerializable(typeof(List<PluginCapabilityDeclaration>))]
|
||||||
|
[JsonSerializable(typeof(PluginSessionHandshakeRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginSessionHandshakeResponse))]
|
||||||
|
[JsonSerializable(typeof(PluginReadyNotification))]
|
||||||
|
[JsonSerializable(typeof(PluginInitializeRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginInitializeResponse))]
|
||||||
|
[JsonSerializable(typeof(PluginStopRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginRestartRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginLifecycleStateChanged))]
|
||||||
|
[JsonSerializable(typeof(PluginSettingsSnapshotRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginSettingsSnapshotResponse))]
|
||||||
|
[JsonSerializable(typeof(PluginSettingsWriteRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginSettingsWriteResponse))]
|
||||||
|
[JsonSerializable(typeof(PluginSettingsChangedNotification))]
|
||||||
|
[JsonSerializable(typeof(PluginAppearanceSnapshotRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginAppearanceSnapshot))]
|
||||||
|
[JsonSerializable(typeof(PluginAppearanceChangedNotification))]
|
||||||
|
[JsonSerializable(typeof(PluginUiSurfaceDescriptor))]
|
||||||
|
[JsonSerializable(typeof(List<PluginUiSurfaceDescriptor>))]
|
||||||
|
[JsonSerializable(typeof(PluginUiAttachRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginUiAttachResponse))]
|
||||||
|
[JsonSerializable(typeof(PluginUiDetachNotification))]
|
||||||
|
[JsonSerializable(typeof(PluginUiCommandRequest))]
|
||||||
|
[JsonSerializable(typeof(PluginUiCommandResponse))]
|
||||||
|
[JsonSerializable(typeof(PluginUiStateChangedNotification))]
|
||||||
|
[JsonSerializable(typeof(PluginHeartbeatPing))]
|
||||||
|
[JsonSerializable(typeof(PluginHeartbeatPong))]
|
||||||
|
[JsonSerializable(typeof(PluginLogEntry))]
|
||||||
|
[JsonSerializable(typeof(PluginFaultReport))]
|
||||||
|
public partial class PluginIsolationJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public static class PluginIsolationProtocolVersion
|
||||||
|
{
|
||||||
|
public const string Current = "1.0";
|
||||||
|
}
|
||||||
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal file
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# LanMountainDesktop.PluginIsolation.Contracts
|
||||||
|
|
||||||
|
Transport-neutral DTOs, route constants, protocol versioning, and JSON serialization context for plugin process isolation.
|
||||||
|
|
||||||
|
## Includes
|
||||||
|
|
||||||
|
- route groups for session, lifecycle, settings, appearance, UI, heartbeat, log, and fault
|
||||||
|
- explicit DTOs for routed request and notification payloads
|
||||||
|
- source-generated `System.Text.Json` context for the IPC protocol
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public sealed record PluginSessionHandshakeRequest(
|
||||||
|
string PluginId,
|
||||||
|
string SessionId,
|
||||||
|
string RuntimeMode,
|
||||||
|
string ProtocolVersion,
|
||||||
|
IReadOnlyList<PluginCapabilityDeclaration>? RequestedCapabilities = null,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
|
public sealed record PluginSessionHandshakeResponse(
|
||||||
|
bool Accepted,
|
||||||
|
string ProtocolVersion,
|
||||||
|
IReadOnlyList<PluginCapabilityDeclaration>? GrantedCapabilities = null,
|
||||||
|
string? ErrorCode = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed record PluginReadyNotification(
|
||||||
|
string PluginId,
|
||||||
|
string SessionId,
|
||||||
|
IReadOnlyList<PluginUiSurfaceDescriptor>? UiSurfaces = null);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public sealed record PluginSettingsSnapshotRequest(
|
||||||
|
string Scope,
|
||||||
|
string? SectionId = null,
|
||||||
|
string? ComponentInstanceId = null);
|
||||||
|
|
||||||
|
public sealed record PluginSettingsSnapshotResponse(
|
||||||
|
string Scope,
|
||||||
|
JsonElement Snapshot,
|
||||||
|
string? ETag = null);
|
||||||
|
|
||||||
|
public sealed record PluginSettingsWriteRequest(
|
||||||
|
string Scope,
|
||||||
|
JsonElement Value,
|
||||||
|
string? SectionId = null,
|
||||||
|
string? ComponentInstanceId = null,
|
||||||
|
string? ETag = null);
|
||||||
|
|
||||||
|
public sealed record PluginSettingsWriteResponse(
|
||||||
|
bool Accepted,
|
||||||
|
string? ETag = null,
|
||||||
|
string? ErrorCode = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed record PluginSettingsChangedNotification(
|
||||||
|
string Scope,
|
||||||
|
JsonElement Value,
|
||||||
|
string? SectionId = null,
|
||||||
|
string? ComponentInstanceId = null,
|
||||||
|
string? ETag = null);
|
||||||
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal file
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public sealed record PluginUiSurfaceDescriptor(
|
||||||
|
string SurfaceId,
|
||||||
|
string SurfaceKind,
|
||||||
|
string Title,
|
||||||
|
string? ComponentId = null);
|
||||||
|
|
||||||
|
public static class PluginUiSurfaceKinds
|
||||||
|
{
|
||||||
|
public const string DesktopComponent = "desktop-component";
|
||||||
|
public const string ComponentEditor = "component-editor";
|
||||||
|
public const string SettingsPage = "settings-page";
|
||||||
|
public const string Window = "window";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginUiAttachRequest(
|
||||||
|
string SurfaceId,
|
||||||
|
string SurfaceKind,
|
||||||
|
string? InstanceId = null,
|
||||||
|
JsonElement? InitialState = null);
|
||||||
|
|
||||||
|
public sealed record PluginUiAttachResponse(
|
||||||
|
bool Accepted,
|
||||||
|
JsonElement? InitialState = null,
|
||||||
|
string? ErrorCode = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed record PluginUiDetachNotification(
|
||||||
|
string SurfaceId,
|
||||||
|
string SurfaceKind,
|
||||||
|
string? InstanceId = null);
|
||||||
|
|
||||||
|
public sealed record PluginUiCommandRequest(
|
||||||
|
string SurfaceId,
|
||||||
|
string CommandName,
|
||||||
|
string? InstanceId = null,
|
||||||
|
JsonElement? Payload = null);
|
||||||
|
|
||||||
|
public sealed record PluginUiCommandResponse(
|
||||||
|
bool Accepted,
|
||||||
|
JsonElement? Payload = null,
|
||||||
|
string? ErrorCode = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed record PluginUiStateChangedNotification(
|
||||||
|
string SurfaceId,
|
||||||
|
string SurfaceKind,
|
||||||
|
string? InstanceId = null,
|
||||||
|
JsonElement? State = null);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<PackageId>LanMountainDesktop.PluginIsolation.Ipc</PackageId>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<Authors>LanMountainDesktop</Authors>
|
||||||
|
<Description>ClassIsland-style IPC facade for LanMountainDesktop plugin process isolation, backed by dotnetCampus.Ipc.</Description>
|
||||||
|
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;dotnetCampus.Ipc</PackageTags>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal file
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||||
|
|
||||||
|
public sealed class PluginIpcClient
|
||||||
|
{
|
||||||
|
public PluginIpcClient(PluginIpcClientOptions options)
|
||||||
|
{
|
||||||
|
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
|
||||||
|
SerializerOptions = SerializerContext.Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginIpcClientOptions Options { get; }
|
||||||
|
|
||||||
|
public JsonSerializerContext SerializerContext { get; }
|
||||||
|
|
||||||
|
public JsonSerializerOptions SerializerOptions { get; }
|
||||||
|
|
||||||
|
public PluginIpcRequestDispatcher? RequestDispatcher { get; set; }
|
||||||
|
|
||||||
|
public PluginIpcNotificationDispatcher? NotificationDispatcher { get; set; }
|
||||||
|
|
||||||
|
public Task<TResponse?> RequestAsync<TRequest, TResponse>(
|
||||||
|
string route,
|
||||||
|
TRequest payload,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||||
|
return RequestCoreAsync<TRequest, TResponse>(route, payload, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyAsync<TPayload>(
|
||||||
|
string route,
|
||||||
|
TPayload payload,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||||
|
return NotifyCoreAsync(route, Serialize(payload), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TResponse?> RequestCoreAsync<TRequest, TResponse>(
|
||||||
|
string route,
|
||||||
|
TRequest payload,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (RequestDispatcher is null)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
|
||||||
|
"Wire RequestDispatcher during host/worker transport integration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await RequestDispatcher(route, Serialize(payload), cancellationToken).ConfigureAwait(false);
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Deserialize<TResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NotifyCoreAsync(string route, JsonElement? payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (NotificationDispatcher is null)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
|
||||||
|
"Wire NotificationDispatcher during host/worker transport integration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await NotificationDispatcher(route, payload, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonElement Serialize<T>(T payload)
|
||||||
|
{
|
||||||
|
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? Deserialize<T>(JsonElement? payload)
|
||||||
|
{
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.Value.Deserialize<T>(SerializerOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user