Compare commits

..

3 Commits

Author SHA1 Message Date
lincube
e9ff590d79 fix.可爱的我一直在修CI( 2026-04-16 14:45:44 +08:00
lincube
1aaf6cd0e9 试试 2026-04-16 14:17:46 +08:00
lincube
2f0c178df2 激进的更新 2026-04-16 01:59:21 +08:00
274 changed files with 1887 additions and 24239 deletions

View File

@@ -1,65 +1,127 @@
# 版本同步说明
# 版本号自动同步说明
## 目标
## 📋 概述
发布版的用户可见版本必须统一指向“应用版本”,不能再出现:
从本次更新开始Release 工作流已配置为**自动同步版本号**,确保应用的每个版本号来源都保持一致。
- Launcher UI 显示 `1.0.0`
- 应用设置页显示 `0.8.x`
- `version.json`、安装包、Release 资产名称各写各的
## 🔄 版本号流转链路
## 默认仓库状态
```
Git Tag (v1.0.1)
[Release 工作流 prepare 任务]
提取版本号: 1.0.1
[Update version in .csproj] ✨ 新增步骤
自动更新 .csproj 文件版本号
dotnet restore/build
构建时读取更新后的版本号
应用内显示版本号 (MainWindow.Localization.cs 动态读取)
```
仓库内的静态版本现在故意保留为开发占位值:
## 🎯 工作原理
- `Directory.Build.props`
- `LanMountainDesktop/LanMountainDesktop.csproj`
- `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
- `LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj`
- `LanMountainDesktop/app.manifest`
- `LanMountainDesktop.Launcher/app.manifest`
### 1. 版本号提取
当推送 Git Tag 时(如 `git tag v1.0.1`Release 工作流的 `prepare` 任务自动提取版本号:
- TAG: `v1.0.1` → VERSION: `1.0.1`
这些值只是提醒“当前不是正式注入构建”,不能代表发布版本。
### 2. 自动更新 .csproj
在三个平台的构建任务中,新增了 **"Update version in .csproj"** 步骤:
## Release 工作流怎么做
**Windows (PowerShell)**:
```powershell
$VERSION = "1.0.1"
(Get-Content file.csproj) -replace '<Version>.*?</Version>', "<Version>$VERSION</Version>" | Set-Content file.csproj
```
Release 工作流会先从 tag 提取版本:
**Linux/macOS (Bash)**:
```bash
VERSION="1.0.1"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" file.csproj
```
- `v0.8.5.2` -> `0.8.5.2`
- 程序集四段版本 -> `0.8.5.2`
### 3. 构建和发布
更新后的版本号被用于:
- 程序集版本 (`AssemblyVersion`)
- 包文件名 (`LanMountainDesktop-1.0.1-win-x64.zip`)
- 应用内显示 (About 页面)
- GitHub Release 标题
随后显式执行:
## 📍 涉及的文件
- `scripts/Set-ReleaseVersion.ps1`
自动更新的文件:
1. `LanMountainDesktop/LanMountainDesktop.csproj`
这个步骤会同步更新:
## ✅ 使用流程
- 主程序 `.csproj``Version`
- Launcher `.csproj``Version`
- Shared.Contracts `.csproj``Version`
- `Directory.Build.props`
- 主程序 `app.manifest`
- Launcher `app.manifest`
### 发布新版本
之后构建和发布阶段继续通过 MSBuild 属性注入:
```bash
# 1. 更新代码(可选:代码中的版本号现在会自动更新)
git add .
git commit -m "feat: Add new features"
- `Version`
- `AssemblyVersion`
- `FileVersion`
- `InformationalVersion`
# 2. 创建版本标签
git tag v1.0.1
# 或带注释的标签
git tag -a v1.0.1 -m "Release v1.0.1"
因此最终会统一落到:
# 3. 推送标签到 GitHub
git push origin v1.0.1
- Launcher UI 读取到的应用版本
- 应用设置页显示的版本
- `version.json`
- 程序集文件版本
- Windows manifest
- 安装包版本
- GitHub Release 资产名称
# 4. Release 工作流自动运行:
# - 自动更新 .csproj 文件
# - 构建所有平台
# - 创建 GitHub Release
# - 附带所有平台的发布包
```
## 维护规则
## 🔒 版本号一致性保证
- 日常开发不要手动把仓库默认版本改成正式版本号。
- 正式发版只需要打 tag版本同步交给工作流。
- 如果新增新的版本承载点,必须同时补到 `Set-ReleaseVersion.ps1` 和 Release 工作流里。
现在应用的三个版本号来源完全同步:
| 来源 | 说明 | 自动更新 |
|------|------|--------|
| `.csproj` <Version> | 项目文件版本 | ✅ 是 |
| 程序集版本 | 编译时读取 | ✅ 是 |
| 应用内显示 | About 页面 | ✅ 是 |
| 发布包文件名 | Release 工作流 | ✅ 是 |
| GitHub Release | Release 工作流 | ✅ 是 |
## ⚠️ 注意事项
### 不需要手动更新
- ❌ 不需要在 `.csproj` 中手动修改 Version
- ❌ 不需要修改多个地方的版本号
### 只需执行
- ✅ 创建 Git Tag: `git tag v1.0.1`
- ✅ 推送 Tag: `git push origin v1.0.1`
- ✅ 其他由工作流自动处理
## 📊 版本号格式
支持的格式:
-`v1.0.0` (builds -> 1.0.0)
-`v1.2.3` (builds -> 1.2.3)
-`v2.0.0-rc1` (builds -> 2.0.0-rc1, 如果需要)
## 🛠️ 工作流文件
更新的工作流文件:
- `.github/workflows/release.yml` - Release 工作流
## 📝 相关文件
- [MULTIPLATFORM_RELEASE_GUIDE.md](./MULTIPLATFORM_RELEASE_GUIDE.md) - 多平台发布指南
- [WORKFLOWS_GUIDE.md](./WORKFLOWS_GUIDE.md) - 工作流使用指南
---
**最后更新**: 2026-03-04
**工作流版本**: 2.0 (自动版本同步)

View File

@@ -32,7 +32,6 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -66,21 +65,12 @@ jobs:
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
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
libxrender1 libxkbcommon-x11-0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -107,14 +97,10 @@ jobs:
fetch-depth: 0
submodules: recursive
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -145,7 +131,6 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Pack SDK and template packages
shell: pwsh

View File

@@ -25,13 +25,12 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}

View File

@@ -1,166 +0,0 @@
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

View File

@@ -1,235 +0,0 @@
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

View File

@@ -30,22 +30,14 @@ jobs:
informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
release_channel: ${{ steps.version.outputs.release_channel }}
steps:
- name: Checkout repository metadata
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info
id: version
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
TAG="${GITHUB_REF#refs/tags/}"
CHECKOUT_REF="${GITHUB_REF}"
IS_PRERELEASE="false"
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
@@ -55,40 +47,19 @@ jobs:
else
TAG="v${RAW_TAG}"
fi
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
CHECKOUT_REF="${GITHUB_SHA}"
fi
VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
VERSION_PARTS+=("0")
done
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
if [[ "${IS_PRERELEASE}" == "true" ]]; then
RELEASE_CHANNEL="preview"
else
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"
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
build-windows:
needs: prepare
@@ -117,14 +88,6 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -137,32 +100,37 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Launcher (AOT)
- name: Publish Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$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
if ($selfContained) {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained `
-r win-$arch `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
} else {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
}
Write-Host "Launcher published to: $launcherPublishDir"
shell: pwsh
- name: Publish Main App
@@ -200,6 +168,9 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
}
Write-Host "Published to: $publishDir"
Write-Host "Self-contained: $selfContained"
shell: pwsh
- name: Restructure for Launcher
@@ -210,58 +181,97 @@ jobs:
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$launcherPublishDir = "publish/launcher-win-$arch"
$appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch"
Write-Host "Restructuring for Launcher mode..."
Write-Host "Version: $version"
Write-Host "Publish dir: $publishDir"
$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
$launcherSource = $launcherPublishDir
if (Test-Path $launcherSource) {
Write-Host "Copying Launcher to root..."
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
} else {
Write-Warning "Launcher publish dir not found: $launcherSource"
}
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
Write-Host "New directory structure:"
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
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
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
shell: pwsh
- name: Build Installer
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$suffix = "${{ matrix.suffix }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$suffix = "${{ matrix.suffix }}"
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
$outputDir = "build-installer"
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
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
}
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
$candidatePaths = @(
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
) | Where-Object { $_ -and (Test-Path $_) }
if (-not (Test-Path -Path $installerScript)) {
Write-Error "Installer script not found: $installerScript"
exit 1
}
$isccPath = $null
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($isccCommand) {
$isccPath = $isccCommand.Source
}
$candidatePaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\bin\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
)
$isccPath = $candidatePaths | Select-Object -First 1
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."
exit 1
}
if (-not (Test-Path $installerScript)) {
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
exit 1
}
Write-Host "Found Inno Setup at: $isccPath"
Write-Host "Building installer for Windows $arch with version $version..."
$publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path
@@ -277,6 +287,8 @@ jobs:
$installerScript
)
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
& $isccPath @compileArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
@@ -288,53 +300,217 @@ jobs:
Write-Error "Failed to create installer"
exit 1
}
Write-Host "Successfully created: $($installerFile.Name)"
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh
- name: Package Payload Zip
- name: Generate Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
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"
$publishDir = "publish/windows-${{ matrix.arch }}"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Determine previous version and download its update.zip for diff ---
$previousVersion = $null
$previousAppPath = $null
try {
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
if ($previousRelease) {
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
Write-Host "Previous release version: $previousVersion"
# Try to download update.zip from previous release for diff
$prevUpdateZip = $previousRelease.assets | Where-Object { $_.name -eq "update.zip" } | Select-Object -First 1
if ($prevUpdateZip) {
Write-Host "Found update.zip in previous release - extracting for diff..."
$prevZipDest = Join-Path $outputDir "prev-update.zip"
Invoke-WebRequest -Uri $prevUpdateZip.browser_download_url -OutFile $prevZipDest -Headers $headers
$previousAppPath = Join-Path $outputDir "prev-app"
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
Expand-Archive -Path $prevZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevZipDest -Force
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
} else {
Write-Host "No update.zip found in previous release - will generate full package"
}
}
} catch {
Write-Host "Could not fetch previous release: $_"
}
# --- Generate file manifest with diff against previous version ---
Write-Host "Generating update package for version $version..."
$files = Get-ChildItem -Path $currentAppPath -Recurse -File
$fileEntries = [System.Collections.ArrayList]::new()
$changedFiles = [System.Collections.ArrayList]::new()
$reusedCount = 0
$addedCount = 0
$replacedCount = 0
$deletedCount = 0
# Build hash map of previous version files for quick lookup
$prevHashMap = @{}
if ($previousAppPath -and (Test-Path $previousAppPath)) {
$prevFiles = Get-ChildItem -Path $previousAppPath -Recurse -File
foreach ($pf in $prevFiles) {
$relPath = $pf.FullName.Substring($previousAppPath.Length).TrimStart('\', '/').Replace('\', '/')
if ($relPath -match '^\.(current|partial|destroy)$') { continue }
$prevHashMap[$relPath] = (Get-FileHash -Path $pf.FullName -Algorithm SHA256).Hash.ToLower()
}
Write-Host "Previous version has $($prevHashMap.Count) files for comparison"
}
foreach ($file in $files) {
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
$relativePath = $relativePath.Replace('\', '/')
# Skip deployment marker files
if ($relativePath -match '^\.(current|partial|destroy)$') {
continue
}
$hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower()
if ($prevHashMap.ContainsKey($relativePath)) {
$prevHash = $prevHashMap[$relativePath]
if ($hash -eq $prevHash) {
$fileEntries += @{ Path = $relativePath; Action = "reuse"; Sha256 = $hash }
$reusedCount++
} else {
$fileEntries += @{ Path = $relativePath; Action = "replace"; Sha256 = $hash; ArchivePath = $relativePath }
$changedFiles += $file
$replacedCount++
}
$prevHashMap.Remove($relativePath)
} else {
$fileEntries += @{ Path = $relativePath; Action = "add"; Sha256 = $hash; ArchivePath = $relativePath }
$changedFiles += $file
$addedCount++
}
}
# Files in previous version but not in current = deleted
foreach ($deletedPath in $prevHashMap.Keys) {
$fileEntries += @{ Path = $deletedPath; Action = "delete" }
$deletedCount++
}
Write-Host "Delta summary: $reusedCount reused, $replacedCount replaced, $addedCount added, $deletedCount deleted"
Write-Host "Changed files to include in update.zip: $($changedFiles.Count)"
$filesJson = @{
FromVersion = $previousVersion
ToVersion = $version
Platform = "windows"
Arch = "x64"
Files = $fileEntries
} | ConvertTo-Json -Depth 10
$filesJsonPath = Join-Path $outputDir "files.json"
$filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8
Write-Host "Generated files.json with $($fileEntries.Count) entries"
# Create update.zip with only changed files
$tempDir = Join-Path $outputDir "temp_staging"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
foreach ($file in $changedFiles) {
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
$destPath = Join-Path $tempDir $relativePath
$destDir = Split-Path -Parent $destPath
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
Copy-Item -Path $file.FullName -Destination $destPath -Force
}
$updateZipPath = Join-Path $outputDir "update.zip"
if ($changedFiles.Count -gt 0) {
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
} else {
# No changed files - create a minimal zip
$emptyMarker = Join-Path $tempDir ".no-changes"
Set-Content -Path $emptyMarker -Value ""
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
}
Remove-Item -Path $tempDir -Recurse -Force
Write-Host "Created update.zip: $([Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)) MB"
# Clean up previous version extraction
if ($previousAppPath -and (Test-Path $previousAppPath)) {
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
}
shell: pwsh
- name: Sign File Map
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$outputDir = "delta-output"
$filesJsonPath = Join-Path $outputDir "files.json"
$signaturePath = Join-Path $outputDir "files.json.sig"
if (-not (Test-Path $filesJsonPath)) {
Write-Error "files.json not found at $filesJsonPath"
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
$privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
exit 0
}
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
if (Test-Path $payloadZip) {
Remove-Item $payloadZip -Force
$privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
using System;
using System.IO;
using System.Security.Cryptography;
public class RsaSigner {
public static void Sign(string jsonPath, string keyPath, string sigPath) {
var jsonBytes = File.ReadAllBytes(jsonPath);
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(keyPath));
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
}
}
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
"@
[RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
Remove-Item -Path $privateKeyPath -Force
Write-Host "Signed files.json -> files.json.sig"
shell: pwsh
- name: Upload Release Assets
- name: Upload Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
name: release-delta-windows-x64
path: |
release-assets/files-windows-${{ matrix.arch }}.zip
build-installer/*.exe
delta-output/files.json
delta-output/files.json.sig
delta-output/update.zip
if-no-files-found: error
retention-days: 90
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
path: build-installer/*.exe
if-no-files-found: error
retention-days: 30
@@ -358,25 +534,12 @@ jobs:
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \
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
libxrender1 libxkbcommon-x11-0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -389,23 +552,18 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Launcher (AOT)
- name: Publish Launcher
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:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
-p:DebugSymbols=false
- name: Publish Main App
run: |
@@ -432,15 +590,25 @@ jobs:
appDir="app-$version"
launcherDir="publish/launcher-linux-x64"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir"
if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi
touch "$publishDir/$appDir/.current"
echo "New directory structure:"
find "$publishDir" -maxdepth 2 | head -50
rm -rf "$launcherDir"
- name: Package as DEB
@@ -453,6 +621,12 @@ jobs:
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
mkdir -p "build-deb/DEBIAN"
mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications"
@@ -461,6 +635,20 @@ jobs:
cp -r "$source"/* "build-deb/usr/local/bin/"
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 \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
@@ -484,9 +672,9 @@ jobs:
printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version"
printf '%s\n' "Architecture: $arch"
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
printf '%s\n' 'Description: LanMountain Desktop Application'
printf '%s\n' ' A desktop application for LanMountain.'
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
printf '%s\n' "Description: LanMountain Desktop Application"
printf '%s\n' " A desktop application for LanMountain."
} > "build-deb/DEBIAN/control"
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
@@ -495,49 +683,26 @@ jobs:
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst"
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
- name: Package Payload Zip
run: |
version="${{ needs.prepare.outputs.version }}"
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"
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
ls -lh "${package_name}_${package_version}_${arch}.deb"
else
echo "Error: Failed to build DEB package"
exit 1
fi
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
- name: Upload
uses: actions/upload-artifact@v4
with:
name: release-linux-x64
path: |
release-assets/files-linux-x64.zip
*.deb
name: release-linux
path: "*.deb"
if-no-files-found: error
retention-days: 30
build-macos:
needs: prepare
runs-on: macos-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }}
@@ -550,21 +715,10 @@ jobs:
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -577,23 +731,18 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Launcher (AOT)
- name: Publish Launcher
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:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
-p:DebugSymbols=false
- name: Publish Main App
run: |
@@ -613,22 +762,7 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- 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: |
version="${{ needs.prepare.outputs.version }}"
arch="${{ matrix.arch }}"
@@ -637,19 +771,41 @@ jobs:
launcherDir="publish/launcher-macos-$arch"
appSourceDir="publish/macos-$arch-app"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "${app_name}.app/Contents/MacOS"
appDir="app-$version"
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
if [ -d "$appSourceDir" ]; then
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
else
echo "Error: Main app source directory not found: $appSourceDir"
exit 1
fi
if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources"
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
{
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">'
@@ -673,96 +829,101 @@ jobs:
mkdir -p dmg-temp
cp -r "${app_name}.app" dmg-temp/
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
- name: Upload Release Assets
if: always()
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
echo "Successfully created: ${package_name}.dmg"
ls -lh "${package_name}.dmg"
else
echo "Error: Failed to create DMG"
exit 1
fi
rm -rf dmg-temp "${app_name}.app"
- name: Upload
uses: actions/upload-artifact@v4
with:
name: release-macos-${{ matrix.arch }}
path: |
release-assets/files-macos-${{ matrix.arch }}.zip
*.dmg
if-no-files-found: ignore
path: "*.dmg"
if-no-files-found: error
retention-days: 30
github-release:
needs: [prepare, build-windows, build-linux]
needs: [ prepare, build-windows, build-linux, build-macos ]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download release artifacts
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: release-files
path: artifacts
pattern: release-*
merge-multiple: true
- name: Normalize release files
- name: List artifacts structure
run: |
mkdir -p release-bundle
echo "Artifact directory structure:"
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'
mapfile -t downloaded_files < <(find release-files -type f)
if [ "${#downloaded_files[@]}" -eq 0 ]; then
echo "No downloaded release artifacts were found."
exit 1
fi
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
- name: Flatten artifacts for release
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)
echo "Organizing artifacts..."
mkdir -p release-files
# Copy installers and packages
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
# Copy delta update files (files.json, files.json.sig, update.zip)
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -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 "No release files were produced."
echo "Error: No release files found"
exit 1
fi
- name: Create or Update Release
- name: Create Release
uses: ncipollo/release-action@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }}
commit: ${{ github.sha }}
allowUpdates: true
draft: false
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
artifacts: 'release-bundle/*'
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
artifacts: "release-files/**"
body: |
## Release ${{ needs.prepare.outputs.version }}
### Installers
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
### Windows
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
### Payload Archives
- `files-windows-x64.zip`
- `files-windows-x86.zip`
- `files-linux-x64.zip`
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (Windows x64)
- **files.json** - Update manifest listing changed files
- **files.json.sig** - RSA signature of the manifest
- **update.zip** - Archive containing changed files
Existing users: The app will automatically detect and apply the incremental update on next launch.
### Linux
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
### macOS
- macOS assets are best-effort and will not block the release.
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
See commits for changes.
token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -512,5 +512,3 @@ nul
/*.deb
/*.dmg
/*.AppImage
/velopack-output-local-verify
/velopack-output-local

View File

@@ -1,805 +0,0 @@
# 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. 清理旧代码

View File

@@ -1,717 +0,0 @@
# 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 ← LauncherAvalonia 应用)
├── 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.ContractsIPC 协议) -->
<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 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常

View File

@@ -1,11 +0,0 @@
# External IPC Public API Checklist
- [x] Host can expose strong-typed public IPC services.
- [x] External .NET client can connect and call built-in services.
- [x] Host publishes launcher startup and loading-state notifications through routed notify.
- [x] Launcher consumes routed notify instead of the old primary custom named-pipe path.
- [x] Plugin SDK exposes public IPC contribution primitives.
- [x] Plugin runtime can discover and register plugin public IPC services.
- [x] Public catalog includes built-in and plugin-contributed services.
- [x] `catalog.changed` is emitted when new services are added after startup.
- [ ] Add example external client sample.

View File

@@ -1,24 +0,0 @@
# External IPC Public API Spec
## Goal
Provide a single `dotnetCampus.Ipc` based external integration layer for:
- Host public APIs
- Launcher/OOBE startup progress and loading-state notifications
- plugin-contributed public services and live event push
## Delivered
- `LanMountainDesktop.Shared.IPC` project
- `[IpcPublic]` based built-in public contracts
- `PublicIpcHostService` and `LanMountainDesktopIpcClient`
- Launcher migrated to Host public IPC notifications
- Plugin SDK public IPC contribution API
- Host runtime integration for plugin public IPC services
## Out of Scope
- plugin process isolation
- non-.NET strong-typed public IPC clients
- live plugin public service removal without restart

View File

@@ -1,12 +0,0 @@
# External IPC Public API Tasks
- [x] Add `LanMountainDesktop.Shared.IPC`
- [x] Expose built-in `[IpcPublic]` services
- [x] Add routed notify constants and public IPC client/host wrappers
- [x] Start Host public IPC during app startup
- [x] Move Launcher startup progress consumption to the new IPC base
- [x] Add plugin public IPC registration/contributor SDK
- [x] Register plugin-contributed public services into Host catalog
- [x] Add integration tests for strong-typed public service access and plugin registration descriptors
- [ ] Expand built-in public service surface beyond the first minimal set
- [ ] Add non-.NET bridge guidance and samples

View File

@@ -1,6 +0,0 @@
- [x] 从桌面、托盘、IPC、组件库进入设置时都会落到同一个设置窗口
- [x] 设置已打开时再次触发设置入口,只会聚焦已有窗口,不会切换成关闭
- [x] 设置窗口始终拥有独立任务栏图标,不受“桌面主窗口在任务栏显示图标”开关影响
- [x] 点击“回到 Windows”后只隐藏或最小化桌面主窗口设置窗口保持可见
- [x] 启用滑入滑出动画后,只有主窗口参与动画,设置窗口不参与
- [x] 点击设置窗口关闭按钮后会真实关闭;再次打开时创建新的居中窗口

View File

@@ -1,78 +0,0 @@
# 独立设置窗口 Spec
## Why
- 当前设置窗口仍然带有桌面壳的 owner / anchor 语义,点击“回到 Windows”或触发桌面动画时容易被一起隐藏或重新定位。
- 产品新增了“在任务栏显示图标”和“启用滑入滑出动画”设置,需要明确边界:它们只影响桌面主窗口,不影响设置窗口。
- 桌面底栏、托盘菜单、IPC、组件库等入口应当始终打开同一个独立设置窗口而不是切换成附属浮窗或开关行为。
## What Changes
- 将设置窗口改为独立顶层窗口,始终使用自己的任务栏按钮和图标。
- `SettingsWindowService.Open` 改为幂等的 open-or-focus重复打开只聚焦已有窗口并在提供目标页时切换到对应页面。
- 移除 `Owner`、锚点定位和 `Toggle` 语义;首次打开按参考屏幕居中,关闭为真实关闭。
- 桌面壳的“回到 Windows”、最小化到托盘/任务栏、滑入滑出动画,只影响 `MainWindow`,不会影响设置窗口。
- 统一桌面、托盘、IPC、组件库等设置入口全部走 `OpenIndependentSettingsModule`
- 设置页文案明确“在任务栏显示图标”只控制桌面主窗口;设置窗口始终保留独立任务栏图标。
## Impact
- Affected code:
- `LanMountainDesktop/Services/Settings/SettingsWindowService.cs`
- `LanMountainDesktop/App.axaml.cs`
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
- `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml`
- Affected behavior:
- 设置窗口生命周期
- 设置入口一致性
- 任务栏图标与桌面壳显示边界
---
## ADDED Requirements
### Requirement: 设置窗口为独立顶层窗口
系统 SHALL 将设置窗口作为独立顶层窗口显示,而不是作为桌面主窗口的附属子窗。
#### Scenario: 设置窗口拥有独立任务栏图标
- **WHEN** 用户打开设置窗口
- **THEN** 设置窗口使用独立顶层窗口方式显示
- **AND THEN** 设置窗口在任务栏中保留自己的独立按钮和图标
- **AND THEN** “在任务栏显示图标”开关不会影响设置窗口的任务栏按钮
### Requirement: 设置入口统一为 open-or-focus
系统 SHALL 让所有设置入口打开或聚焦同一个设置窗口实例。
#### Scenario: 已打开时重复触发设置入口
- **WHEN** 设置窗口已经打开,用户再次从桌面、托盘或 IPC 触发打开设置
- **THEN** 系统只聚焦现有设置窗口
- **AND THEN** 如果请求包含目标页,则导航到目标页
- **AND THEN** 不会把已打开的设置窗口当作开关关闭
### Requirement: 设置窗口不参与桌面壳可见性切换
系统 SHALL 让桌面壳的隐藏、最小化和进出场动画只作用于主窗口。
#### Scenario: 回到 Windows 时设置窗口保持可见
- **WHEN** 主窗口执行“回到 Windows”并隐藏到托盘或最小化到任务栏
- **THEN** 设置窗口保持当前可见状态
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
#### Scenario: 桌面滑入滑出动画不作用于设置窗口
- **WHEN** 启用了滑入滑出动画并触发主窗口退场或入场
- **THEN** 只有主窗口参与动画
- **AND THEN** 设置窗口不会消失,也不会跟随主窗口做进出场动画
### Requirement: 关闭设置窗口时真实销毁实例
系统 SHALL 在用户关闭设置窗口时真实关闭该窗口实例。
#### Scenario: 关闭后再次打开
- **WHEN** 用户点击设置窗口右上角关闭按钮
- **THEN** 当前设置窗口实例被关闭并销毁
- **AND THEN** 下次再次打开设置时创建新的设置窗口实例
- **AND THEN** 新窗口按参考屏幕居中显示

View File

@@ -1,25 +0,0 @@
# Tasks
- [x] Task 1: 简化设置窗口打开契约
- [x]`SettingsWindowOpenRequest` 从 owner / anchor 语义改为目标页 + 参考屏幕语义
- [x] 移除 `ISettingsWindowService.Toggle`
- [x] Task 2: 重做设置窗口服务行为
- [x] 设置窗口始终使用 `Show()` 打开
- [x] 设置窗口始终 `ShowInTaskbar = true`
- [x] 已打开时只聚焦并在需要时切页
- [x] 关闭后销毁实例,下次打开重新创建并居中
- [x] Task 3: 统一设置入口并解耦桌面壳
- [x] 桌面底栏设置按钮改为 open-or-focus
- [x] 组件库入口改为复用 `OpenIndependentSettingsModule`
- [x] 移除 `MainWindow` 上的设置窗口锚点逻辑
- [x] Task 4: 明确产品边界
- [x] 调整“在任务栏显示图标”文案,限定为桌面主窗口
- [x] 新增独立设置窗口 feature spec
- [x] 在窗口过渡动画 spec 中补充“设置窗口不参与动画”
- [x] Task 5: 验证
- [x] 运行 `dotnet build LanMountainDesktop.slnx -c Debug`
- [x] 运行与新 helper 相关的测试

View File

@@ -1,8 +0,0 @@
# 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.

View File

@@ -1,43 +0,0 @@
# 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.

View File

@@ -1,9 +0,0 @@
# 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.

View File

@@ -1,10 +0,0 @@
# 验收清单
- [ ] 设置页重启后Launcher 能重新接管并恢复到正确展示形态。
- [ ] 插件升级辅助程序完成后,回拉的是 Launcher 而不是宿主 exe。
- [ ] 已在托盘中的实例再次启动时,不会出现第二个主进程。
- [ ] 托盘初始化失败时,应用不会进入无入口的 `TrayOnly`
- [ ] 托盘运行中丢失时watchdog 能重建或自动恢复前台。
- [ ] Launcher UI 版本与应用设置页版本一致。
- [ ] 发布 tag `vX.Y.Z.W`manifest、程序集、`version.json`、安装包和资产命名一致。
- [ ] 100% / 150% / 200% / 250% 缩放下Launcher OOBE、主窗口、通知动画正常。

View File

@@ -1,67 +0,0 @@
# Launcher 外壳托管、托盘兜底与高分屏动画修复
## 背景
当前桌面应用在以下场景存在明显不稳定性:
- 设置页或升级后的“重启”没有统一回到 Launcher。
- 已有实例处于托盘时,再次启动容易误报“窗口未显示”,甚至重复拉起。
- 托盘初始化失败或运行中丢失时,应用可能进入无恢复入口状态。
- Launcher 和宿主的版本来源不一致,发布后容易出现 UI 版本错乱。
- 高分屏和混合缩放环境下Launcher OOBE、主窗口入场和通知动画存在像素/DIP 混用问题。
## 目标
- Launcher 成为正式环境唯一的启动与重启入口。
- 进入 `TrayOnly` 前必须先确认托盘可恢复。
- Launcher UI 显示的版本号等于应用版本号。
- 发布工作流显式同步主程序、Launcher、manifest 和产物版本。
- 动画和定位统一按 DIP 与缩放计算。
## 行为要求
### 1. 重启接管
- 应用内重启、插件升级后的重启都必须优先回到 Launcher。
- Launcher 对 `SecondaryActivationSucceeded` 只认定为一次成功重定向,不允许再做 fallback 二次拉起。
- Launcher 启动成功判定区分三类场景:
- 前台启动:`DesktopVisible``ActivationRedirected`
- 重启到最小化:`BackgroundReady`
- 重启到托盘:`TrayReady + BackgroundReady`
### 2. 托盘硬约束
- 托盘状态机必须至少覆盖:
- `Unavailable`
- `Initializing`
- `Ready`
- `Recovering`
- `Failed`
- `HideMainWindowToTray`、关闭到托盘、重启恢复到托盘前都必须先执行托盘就绪检查。
- 如果托盘不可用:
- 优先回退到任务栏最小化
- 若任务栏入口也不可用,则强制恢复前台可见
- 托盘处于隐藏态期间必须运行 watchdog连续恢复失败时自动恢复主窗口。
### 3. 版本来源
- Launcher 只能显示应用版本,不能显示 Launcher 自身硬编码版本。
- 版本解析优先顺序:
- `version.json`
- 主程序文件版本 / 信息版本
- `app-<version>` 部署目录
- Release 工作流必须显式打版本补丁,避免仓库默认占位值被误当成正式版本。
### 4. 高分屏动画
- 主窗口、通知、Launcher OOBE 的动画位移必须使用 DIP 或基于缩放换算后的尺寸。
- 不允许直接把 `PixelRect` 宽高当作 `TranslateTransform``DesiredSize` 的输入。
- 淡入和位移动画应并行执行,避免先淡入后滑动造成观感异常。
## 验收
- 已在托盘中的实例再次通过 Launcher 启动时,只激活已有实例。
- 设置页重启和插件升级重启后,不再出现“窗口未显示但后台已有多个进程”。
- 托盘失败时应用仍保持可恢复。
- Launcher 与应用设置页显示相同版本。
- 100% / 150% / 200% / 250% 缩放下Launcher OOBE、主窗口入场、通知位置与动画正常。

View File

@@ -1,14 +0,0 @@
# 任务拆解
- [x] 为 Launcher/宿主共享新增重启来源、父进程和展示模式参数。
- [x] 修复 Launcher 对 `SecondaryActivationSucceeded` 的重复 fallback 拉起。
- [x] 让 Launcher 成功判定支持 `TrayReady``BackgroundReady`
- [x] 应用重启默认优先回到 Launcher而不是直接回拉宿主 exe。
- [x] 抽出独立托盘服务集中处理创建、刷新、watchdog 与状态流转。
- [x] 在进入 `TrayOnly` 前增加托盘就绪校验与回退策略。
- [x] 为运行中托盘丢失增加 watchdog 和自动恢复逻辑。
- [x] 统一公共 IPC、设置页与 Launcher 的版本读取入口。
- [x] 将仓库默认版本改为开发占位值,并在 Release 工作流中加入显式打版本步骤。
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
- [x] 补充规格与版本同步说明文档。
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。

View File

@@ -6,6 +6,3 @@
- [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`.

View File

@@ -52,9 +52,3 @@ Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
- `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.

View File

@@ -1,13 +0,0 @@
# 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.

View File

@@ -1,44 +0,0 @@
# 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`

View File

@@ -1,15 +0,0 @@
# 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.

View File

@@ -1,12 +0,0 @@
# 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 回路

View File

@@ -1,41 +0,0 @@
# 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 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。

View File

@@ -1,12 +0,0 @@
# 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 壳层适配器
- [ ] 为故障、心跳、降级与恢复补齐端到端测试

View File

@@ -1,5 +0,0 @@
# Checklist (Deprecated)
- [x] Spec marked as deprecated.
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
- [x] No release workflow dependency remains on VeloPack.

View File

@@ -1,15 +0,0 @@
# 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.

View File

@@ -1,6 +0,0 @@
# 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).

View File

@@ -113,15 +113,6 @@
- **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`320ms`FluttermotionToken.Duration.Intro`400ms
- **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`DecelerateBezier
### Requirement: 设置窗口不参与桌面壳过渡动画
系统 SHALL 将桌面壳进出场动画限制在主窗口范围内,不影响独立设置窗口。
#### Scenario: 设置窗口在桌面动画期间保持独立
- **WHEN** 主窗口执行滑入、滑出、最小化或恢复动画
- **THEN** 设置窗口不参与该动画
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
## MODIFIED Requirements
### Requirement: OnMinimizeClick 行为

View File

@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.0.0-dev</Version>
<Version>1.0.0</Version>
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>

View File

@@ -46,8 +46,8 @@ public sealed class DesktopShellHost : IDesktopShellHost
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Exit += (_, _) => _performExitCleanup();
_startActivationListener();
_createAndAssignMainWindow(desktop);
_startActivationListener();
}
_startWeatherRefresh();

View File

@@ -1,9 +1,8 @@
<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 />
<FluentTheme />
</Application.Styles>
</Application>

View File

@@ -1,11 +1,8 @@
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;
@@ -13,15 +10,6 @@ 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);
}
@@ -29,314 +17,34 @@ public partial class App : Application
{
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);
// TODO: 从配置读取 GitHub 仓库信息
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
updateCheckService,
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()
};
_ = RunCoordinatorAsync(desktop, coordinator);
}
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);
}
base.OnFrameworkInitializationCompleted();
}
private static async Task RunCoordinatorAsync(
IClassicDesktopStyleApplicationLifetime desktop,
LauncherFlowCoordinator coordinator)
{
var result = await coordinator.RunAsync().ConfigureAwait(false);
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), 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);
}
}

View File

@@ -1,32 +0,0 @@
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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,11 +1,8 @@
-----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-----
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
-----END RSA PUBLIC KEY-----

View File

@@ -4,65 +4,22 @@ 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)
private CommandContext(string command, string subCommand, Dictionary<string, string> options)
{
Command = command;
SubCommand = subCommand;
Options = options;
RawArgs = rawArgs;
}
public static CommandContext FromArgs(string[] args)
@@ -75,7 +32,7 @@ internal sealed class CommandContext
? args[1]
: string.Empty;
return new CommandContext(command, subCommand, options, args);
return new CommandContext(command, subCommand, options);
}
public string? GetOption(string key)
@@ -91,45 +48,6 @@ internal sealed class CommandContext
: 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",
"restart" => "restart",
"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);
@@ -147,13 +65,6 @@ internal sealed class CommandContext
continue;
}
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++i];

View File

@@ -1,66 +0,0 @@
<!-- 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>
<!-- 禁用 ReadyToRunAOT 不需要) -->
<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>

View File

@@ -1,29 +0,0 @@
<!-- 单文件发布配置文件(非 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>

View File

@@ -1,40 +1,30 @@
<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>0.0.0-dev</Version>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- 应用程序图标 -->
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.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.Themes.Fluent" Version="11.3.12" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>
<!-- 资源文件 -->
<!-- Embed public-key.pem and copy to .launcher/update/ in output directory -->
<ItemGroup>
<!-- 公钥文件 -->
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
<!-- Avalonia 资源文件 -->
<AvaloniaResource Include="Assets\logo.ico" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">

View File

@@ -1,63 +0,0 @@
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);

View File

@@ -53,92 +53,3 @@ internal sealed class UpdateApplyResult
public string? RolledBackTo { get; init; }
}
internal sealed class PlondsUpdateMetadata
{
public string? DistributionId { get; set; }
public string? Channel { get; set; }
public string? SubChannel { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? FileMapPath { get; set; }
public string? FileMapSignaturePath { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsFileMap
{
public string? DistributionId { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Version { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsComponentEntry> Components { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
public string? Version { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsFileEntry
{
public string Path { get; set; } = string.Empty;
public string? Action { get; set; } = "replace";
public string? Url { get; set; }
public string? ObjectUrl { get; set; }
public string? ObjectPath { get; set; }
public string? ObjectKey { get; set; }
public string? ArchivePath { get; set; }
public string? Sha256 { get; set; }
public string? Sha512 { get; set; }
public string? Sha512Base64 { get; set; }
public byte[]? Sha512Bytes { get; set; }
public PlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsHashDescriptor
{
public string? Algorithm { get; set; }
public string? Value { get; set; }
public byte[]? Bytes { get; set; }
}

View File

@@ -0,0 +1 @@
MessageBox

View File

@@ -1,7 +1,14 @@
using System.Diagnostics;
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
#if WINDOWS
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
#endif
namespace LanMountainDesktop.Launcher;
internal static class Program
@@ -10,62 +17,170 @@ internal static class Program
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>"}'.");
// 处理遗留插件安装命令
if (commandContext.IsLegacyPluginInstall)
{
var installer = new PluginInstallerService();
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
}
// 处理其他 CLI 命令 (update, plugin, rollback 等)
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
{
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
}
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
private static int LaunchMainApplication(string[] args)
{
// 获取可执行文件名
string executableName = OperatingSystem.IsWindows()
? "LanMountainDesktop.exe"
: "LanMountainDesktop";
// 获取安装根目录
var rootDir = Path.GetFullPath(Path.GetDirectoryName(Environment.ProcessPath) ?? "");
// 查找最佳版本
var installation = FindBestVersion(rootDir, executableName);
if (installation == null)
{
ShowError("找不到有效的 LanMountainDesktop 版本,可能是安装已损坏。\n请访问 https://github.com/ClassIsland/LanMountainDesktop 重新下载并安装。");
return 1;
}
var exePath = Path.Combine(installation, executableName);
// Linux/macOS: 自动添加可执行权限
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
try
{
var chmod = Process.Start(new ProcessStartInfo
{
FileName = "chmod",
Arguments = $"+x \"{exePath}\"",
CreateNoWindow = true
});
chmod?.WaitForExit();
}
catch (Exception ex)
{
Console.Error.WriteLine($"无法设置可执行权限: {ex.Message}");
}
}
// 清理待删除的旧版本
CleanupDestroyedVersions(rootDir);
// 启动主程序
var startInfo = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = rootDir,
UseShellExecute = false
};
foreach (var arg in args)
{
startInfo.ArgumentList.Add(arg);
}
// 传递包根目录环境变量
startInfo.EnvironmentVariables["LanMountainDesktop_PackageRoot"] = rootDir;
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;
Process.Start(startInfo);
return 0;
}
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);
ShowError($"启动主程序失败: {ex.Message}");
return 1;
}
}
private static string? FindBestVersion(string rootDir, string executableName)
{
return Directory.GetDirectories(rootDir)
.Where(x =>
{
var dirName = Path.GetFileName(x);
return dirName.StartsWith("app-") &&
!File.Exists(Path.Combine(x, ".destroy")) &&
!File.Exists(Path.Combine(x, ".partial")) &&
File.Exists(Path.Combine(x, executableName));
})
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1) // .current 优先
.ThenByDescending(x => ParseVersion(Path.GetFileName(x))) // 版本号降序
.FirstOrDefault();
}
private static Version ParseVersion(string dirName)
{
// 从 "app-1.0.0" 格式解析版本号
var parts = dirName.Split('-');
if (parts.Length >= 2 && Version.TryParse(parts[1], out var version))
{
return version;
}
return new Version(0, 0, 0);
}
private static void CleanupDestroyedVersions(string rootDir)
{
try
{
var destroyedDirs = Directory.GetDirectories(rootDir)
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
foreach (var dir in destroyedDirs)
{
try
{
Directory.Delete(dir, recursive: true);
}
catch
{
// 忽略删除失败(可能文件被占用),下次启动再试
}
}
}
catch
{
// 忽略清理失败
}
}
private static void ShowError(string message)
{
#if WINDOWS
try
{
PInvoke.MessageBox(
HWND.Null,
message,
"LanMountainDesktop Launcher",
MESSAGEBOX_STYLE.MB_ICONERROR | MESSAGEBOX_STYLE.MB_OK
);
}
catch
{
Console.Error.WriteLine(message);
}
#else
Console.Error.WriteLine(message);
#endif
}
private static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()

View File

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

View File

@@ -91,7 +91,11 @@ internal static class Commands
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
"download" => 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),
_ => new LauncherResult
{
Success = false,
@@ -102,15 +106,6 @@ internal static class Commands
};
}
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,
@@ -154,7 +149,10 @@ internal static class Commands
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}
@@ -167,27 +165,10 @@ internal static class Commands
}
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;
return File.Exists(parentHost) ? parent : baseDir;
}
}

View File

@@ -29,12 +29,4 @@ internal sealed class DeferredSplashStageReporter : ISplashStageReporter
_pending.Add((stage, message));
}
}
public void ReportStage(string stage, int progress)
{
if (_inner is not null)
{
_inner.ReportStage(stage, progress);
}
}
}

View File

@@ -1,7 +1,4 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
using System.Globalization;
namespace LanMountainDesktop.Launcher.Services;
@@ -18,264 +15,49 @@ internal sealed class DeploymentLocator
public string? FindCurrentDeploymentDirectory()
{
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
var candidates = Directory.Exists(_appRoot)
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
if (!Directory.Exists(_appRoot))
{
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
return null;
}
// 过滤掉无效的部署目录
var validCandidates = candidates
.Where(path =>
!File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除
!File.Exists(Path.Combine(path, ".partial"))) // 排除未完成
.ToList();
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 鏍囪鐨勬帓鍓嶉潰
.ThenByDescending(x => x.Version) // 鐒跺悗鎸夌増鏈彿闄嶅簭
.ToList();
if (validInstallations.Count == 0)
// 优先选择带 .current 标记的版本
var withMarkers = validCandidates
.Where(path => File.Exists(Path.Combine(path, ".current")))
.Select(path => new
{
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
return null;
}
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
var best = validInstallations[0];
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
return best.Path;
}
catch (Exception ex)
if (withMarkers.Count > 0)
{
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);
return withMarkers[0].Path;
}
if (resolvedPath is null && context.IsDebugMode)
{
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
}
if (resolvedPath is null)
{
resolvedPath = ResolveHostExecutablePathLegacy();
if (!string.IsNullOrWhiteSpace(resolvedPath))
// 如果没有 .current 标记,选择最新版本
var byVersion = validCandidates
.Select(path => new
{
searchedPaths.Add(Path.GetFullPath(resolvedPath));
source = "legacy_fallback";
}
}
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
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()
};
return byVersion.Count > 0 ? byVersion[0].Path : null;
}
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))
{
@@ -294,91 +76,7 @@ internal sealed class DeploymentLocator
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
if (File.Exists(inParent))
{
return inParent;
}
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
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. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
if (File.Exists(devPath))
{
return devPath;
}
}
return null;
}
/// <summary>
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
/// </summary>
private static string? ScanDevelopmentPaths(string executable)
{
var possiblePaths = new[]
{
// 浠?Launcher 椤圭洰杩愯
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),
// 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>
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable)
{
var launcherDir = AppContext.BaseDirectory;
var possiblePaths = new[]
{
// 浠?Launcher 椤圭洰杩愯锛?.\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),
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯锛歀anMountainDesktop\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 鐩綍杩愯
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
};
return possiblePaths.Select(Path.GetFullPath).Distinct();
return File.Exists(inParent) ? inParent : null;
}
public string GetCurrentVersion()
@@ -408,155 +106,35 @@ internal sealed class DeploymentLocator
}
}
/// <summary>
/// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? /// </summary>
/// <param name="minVersionsToKeep">鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
public void CleanupDestroyedDeployments()
{
try
{
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
var candidates = Directory.Exists(_appRoot)
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
if (!Directory.Exists(_appRoot))
{
return;
}
var destroyedDirs = candidates
.Where(path => File.Exists(Path.Combine(path, ".destroy")));
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");
// 纭畾瑕佷繚鐣欑殑鐗堟湰
var versionsToKeep = new HashSet<string>();
// 1. 鎬绘槸淇濈暀褰撳墠鐗堟湰
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion != null)
{
versionsToKeep.Add(currentVersion.Path);
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
}
// 2. 淇濈暀鏈€杩戠殑N涓湁鏁堢増鏈紙涓嶅寘鎷凡鏍囪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. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
if (Directory.Exists(snapshotDir))
foreach (var dir in destroyedDirs)
{
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
{
// 蹇界暐蹇収瑙f瀽閿欒
}
}
Directory.Delete(dir, recursive: true);
}
catch
{
// 蹇界暐蹇収鐩綍璁块棶閿欒
}
}
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
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
{
// 蹇界暐鍙栨秷鏍囪澶辫触
}
}
continue;
}
if (!deployment.IsDestroyed)
{
try
{
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
}
catch
{
// 蹇界暐鏍囪澶辫触
}
}
// 灏濊瘯鍒犻櫎
try
{
Directory.Delete(deployment.Path, recursive: true);
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
}
catch
{
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
// 忽略删除失败(可能文件被占用),下次启动再试
}
}
}
catch (Exception ex)
catch
{
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
// 蹇界暐娓呯悊澶辫触
// 忽略清理失败
}
}
/// <summary>
/// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
/// </summary>
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
{
CleanupOldDeployments(3);
}
public static Version ParseVersionFromDirectory(string path)
{
var text = ParseVersionTextFromDirectory(path);
@@ -579,19 +157,4 @@ internal sealed class DeploymentLocator
return segments[1];
}
/// <summary>
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </summary>
public AppVersionInfo GetVersionInfo()
{
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var resolved = AppVersionProvider.ResolveFromPackageRoot(_appRoot, executableName);
return string.IsNullOrWhiteSpace(resolved.Version)
? new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate"
}
: resolved;
}
}

View File

@@ -1,629 +0,0 @@
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. 使用 DeploymentLocatorClassIsland 风格的简洁查询 - 优先)
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;
}
// 尝试添加 .exeWindows
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; }
}

View File

@@ -1,47 +0,0 @@
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;
}

View File

@@ -1,18 +0,0 @@
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; } = [];
}

View File

@@ -3,9 +3,4 @@ namespace LanMountainDesktop.Launcher.Services;
internal interface ISplashStageReporter
{
void Report(string stage, string message);
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
void ReportStage(string stage, int progress);
}

View File

@@ -1,192 +0,0 @@
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 { }
}
}

View File

@@ -1,30 +0,0 @@
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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,344 +0,0 @@
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 // 便携版
}

View File

@@ -1,138 +0,0 @@
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
{
}
}
}

View File

@@ -1,221 +1,29 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class OobeStateService
{
private const int CurrentSchemaVersion = 1;
private readonly string _markerPath;
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)
public OobeStateService(string appRoot)
{
_ = 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");
var stateDir = Path.Combine(appRoot, ".launcher", "state");
Directory.CreateDirectory(stateDir);
_markerPath = Path.Combine(stateDir, "first_run_completed");
}
public OobeLaunchDecision Evaluate(CommandContext context)
public bool IsFirstRun()
{
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;
return !File.Exists(_markerPath);
}
public OobeCompletionResult MarkCompleted(CommandContext context)
public void MarkCompleted()
{
try
var dir = Path.GetDirectoryName(_markerPath);
if (!string.IsNullOrWhiteSpace(dir))
{
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");
Directory.CreateDirectory(dir);
}
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");
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
}
}

View File

@@ -1,18 +1,11 @@
using System.IO.Compression;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
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),
@@ -30,11 +23,6 @@ internal sealed class PluginInstallerService
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));
@@ -56,84 +44,37 @@ internal sealed class PluginInstallerService
};
}
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))
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.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;
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
@@ -247,7 +188,7 @@ internal sealed class PluginInstallerService
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PackageFileExtension;
return fileName + PluginSdkInfo.PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
@@ -257,15 +198,3 @@ internal sealed class PluginInstallerService
: 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; }
}

View File

@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
@@ -63,7 +63,10 @@ internal sealed class PluginUpgradeQueueService
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
{
WriteIndented = true
}));
}
return new LauncherResult
@@ -76,19 +79,19 @@ internal sealed class PluginUpgradeQueueService
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
};
}
}
internal sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
private sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}
}

View File

@@ -15,6 +15,7 @@ internal sealed class UpdateCheckService
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public UpdateCheckService(string repoOwner, string repoName)
{
@@ -23,6 +24,12 @@ internal sealed class UpdateCheckService
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
}
/// <summary>
@@ -90,7 +97,7 @@ internal sealed class UpdateCheckService
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
return releases?.Select(r => new ReleaseInfo
{
@@ -124,38 +131,38 @@ internal sealed class UpdateCheckService
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; }
// GitHub API 响应模型
private 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; }
}
private 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; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
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();
}
});
}
}

View File

@@ -1,263 +0,0 @@
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

View File

@@ -1,67 +0,0 @@
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);
}
}

View File

@@ -1,182 +0,0 @@
<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>

View File

@@ -1,196 +0,0 @@
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);
}
}

View File

@@ -1,107 +0,0 @@
<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>

View File

@@ -1,172 +0,0 @@
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!");
}
}
}

View File

@@ -1,105 +0,0 @@
<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="&#xEA39;"
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>

View File

@@ -1,542 +0,0 @@
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
}

View File

@@ -1,234 +0,0 @@
<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="&#xE768;"
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="&#xE783;"
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>

View File

@@ -1,392 +0,0 @@
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"))
};
}

View File

@@ -1,149 +0,0 @@
<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="&#xE7BA;"
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>

View File

@@ -1,157 +0,0 @@
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
}

View File

@@ -1,80 +1,22 @@
<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"
Title="阑山桌面"
Width="420"
Height="260"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:OobeWindow />
</Design.DataContext>
<Grid x:Name="ContentGrid"
Opacity="0">
<Grid.RenderTransform>
<TranslateTransform Y="24" />
</Grid.RenderTransform>
<!-- 主内容区域 -->
<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>
WindowStartupLocation="CenterScreen">
<Grid Margin="24" RowDefinitions="*,Auto">
<TextBlock Text="欢迎使用阑山桌面"
FontSize="26"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
<Button Grid.Row="1"
x:Name="EnterButton"
HorizontalAlignment="Right"
Width="64"
Height="40"
Content="→"
FontSize="18" />
</Grid>
</Window>

View File

@@ -1,182 +1,27 @@
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;
public partial class OobeWindow : Window
internal partial class OobeWindow : Window
{
private readonly TaskCompletionSource<bool> _completionSource = new();
private bool _isTransitioning;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
}
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!");
}
}
private async void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
await PlayEntranceAnimationAsync();
}
private async Task PlayEntranceAnimationAsync()
{
try
{
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
return;
}
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
contentGrid.RenderTransform = translateTransform;
var offset = ResolveEntranceOffset();
contentGrid.Opacity = 0;
translateTransform.Y = offset;
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, offset) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(600)
}
}
};
await Task.WhenAll(
fadeInAnimation.RunAsync(contentGrid),
slideUpAnimation.RunAsync(translateTransform));
Console.WriteLine("[OobeWindow] Entrance animation completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
}
}
public Task WaitForEnterAsync() => _completionSource.Task;
private async void OnEnterClick(object? sender, RoutedEventArgs e)
private void OnEnterClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning)
{
return;
}
_isTransitioning = true;
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
try
{
await PlayExitAnimationAsync();
_completionSource.TrySetResult(true);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
_completionSource.TrySetResult(true);
}
}
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}");
}
}
private double ResolveEntranceOffset()
{
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
var scaledOffset = boundsHeight * 0.05;
return Math.Clamp(scaledOffset, 20, 48);
_completionSource.TrySetResult(true);
}
}

View File

@@ -1,87 +1,40 @@
<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"
Title="阑山桌面"
Width="420"
Height="240"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:SplashWindow />
</Design.DataContext>
<Grid>
<!-- 左上角:应用名称 -->
SystemDecorations="None">
<Grid Margin="24" RowDefinitions="*,Auto,Auto,Auto">
<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>
Text="阑山桌面"
FontSize="34"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Grid.Row="0" />
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
Margin="0,12,0,0"
IsIndeterminate="True" />
<TextBlock x:Name="StageText"
Grid.Row="2"
FontSize="12"
Foreground="#999999"
HorizontalAlignment="Center"
Margin="0,8,0,0"
Text="" />
<TextBlock x:Name="DetailText"
Grid.Row="3"
FontSize="11"
Foreground="#BBBBBB"
HorizontalAlignment="Center"
Margin="0,2,0,0"
Text="" />
</Grid>
</Window>

View File

@@ -1,250 +1,48 @@
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
internal partial class SplashWindow : Window, ISplashStageReporter
{
private int _versionTextClickCount = 0;
private const int DebugModeClickThreshold = 5;
private bool _isDebugModeOpened = false;
private static readonly (string Stage, string Label, double Progress)[] StageMap =
[
("bootstrap", "正在初始化...", 10),
("silentUpdate", "正在应用更新...", 35),
("pluginTasks", "正在处理插件...", 65),
("launchHost", "正在启动...", 90),
];
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;
}
var (label, progress) = ResolveStageInfo(stage);
// 更新状态文本
statusText.Text = message;
var stageText = this.GetControl<TextBlock>("StageText");
var detailText = this.GetControl<TextBlock>("DetailText");
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
// 根据阶段更新进度
var progress = ResolveProgress(stage);
if (progress > 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progress;
}
else
{
progressIndicator.IsIndeterminate = true;
}
});
stageText.Text = label;
detailText.Text = message;
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progress;
}
/// <summary>
/// 更新进度0-100
/// </summary>
public void UpdateProgress(int percent, string? message = null)
private static (string Label, double Progress) ResolveStageInfo(string stage)
{
Dispatcher.UIThread.Post(() =>
foreach (var (s, label, progress) in StageMap)
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
if (string.Equals(s, stage, StringComparison.OrdinalIgnoreCase))
{
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
return;
return (label, progress);
}
}
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
};
return (stage, 0);
}
}

View File

@@ -1,108 +0,0 @@
<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="&#xE921;"
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>

View File

@@ -1,123 +0,0 @@
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 = "预览更新进度界面";
}
});
}
}

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.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>

View File

@@ -1,12 +0,0 @@
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);

View File

@@ -1,45 +0,0 @@
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";
}

View File

@@ -1,22 +0,0 @@
<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>

View File

@@ -1,33 +0,0 @@
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";
}

View File

@@ -1,17 +0,0 @@
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";
}

View File

@@ -1,15 +0,0 @@
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";
}

View File

@@ -1,56 +0,0 @@
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";
}
}

View File

@@ -1,39 +0,0 @@
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;

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIsolationProtocolVersion
{
public const string Current = "1.0";
}

View File

@@ -1,9 +0,0 @@
# 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

View File

@@ -1,21 +0,0 @@
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);

View File

@@ -1,33 +0,0 @@
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);

View File

@@ -1,52 +0,0 @@
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);

View File

@@ -1,27 +0,0 @@
<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>

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