mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9efa43d92b | ||
|
|
53ff98f66d | ||
|
|
6c526ffdd2 | ||
|
|
3957d81948 | ||
|
|
81ee19f360 | ||
|
|
59c4824425 | ||
|
|
e9ff590d79 | ||
|
|
1aaf6cd0e9 | ||
|
|
2f0c178df2 |
152
.github/VERSION_SYNC_INFO.md
vendored
152
.github/VERSION_SYNC_INFO.md
vendored
@@ -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 (自动版本同步)
|
||||
|
||||
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@@ -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 }}
|
||||
@@ -69,18 +68,10 @@ jobs:
|
||||
libxrender1 libxkbcommon-x11-0 \
|
||||
clang zlib1g-dev
|
||||
|
||||
# Ubuntu 24.04+ moved several packages to t64 names.
|
||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||
|
||||
# Prefer modern WebKit package, fallback for older images.
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -107,14 +98,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 +132,6 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Pack SDK and template packages
|
||||
shell: pwsh
|
||||
|
||||
3
.github/workflows/code-quality.yml
vendored
3
.github/workflows/code-quality.yml
vendored
@@ -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 }}
|
||||
|
||||
166
.github/workflows/ddss-publish.yml
vendored
166
.github/workflows/ddss-publish.yml
vendored
@@ -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
|
||||
235
.github/workflows/plonds-build.yml
vendored
235
.github/workflows/plonds-build.yml
vendored
@@ -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
|
||||
677
.github/workflows/release.yml
vendored
677
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
@@ -143,6 +106,9 @@ jobs:
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
|
||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
||||
|
||||
# AOT 单文件发布
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||
-c Release `
|
||||
-o ./$launcherPublishDir `
|
||||
@@ -153,16 +119,27 @@ jobs:
|
||||
-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 }}
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Launcher AOT publish failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 显示发布结果
|
||||
Write-Host "Launcher published to: $launcherPublishDir"
|
||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
$size = [Math]::Round($exeFile.Length / 1MB, 2)
|
||||
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
||||
}
|
||||
|
||||
# 清理不必要的文件(AOT 单文件应该只有一个 exe)
|
||||
$files = Get-ChildItem -Path $launcherPublishDir -File
|
||||
if ($files.Count -gt 1) {
|
||||
Write-Host "Warning: Expected single file but found $($files.Count) files"
|
||||
$files | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish Main App
|
||||
@@ -200,6 +177,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 +190,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 +296,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 +309,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
|
||||
|
||||
@@ -359,24 +544,12 @@ jobs:
|
||||
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
|
||||
clang zlib1g-dev
|
||||
|
||||
- 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 }}
|
||||
@@ -391,6 +564,8 @@ jobs:
|
||||
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for Linux x64..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-linux-x64 \
|
||||
@@ -401,11 +576,15 @@ jobs:
|
||||
-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 }}
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-linux-x64"
|
||||
ls -lh ./publish/launcher-linux-x64/
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
@@ -432,15 +611,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 +642,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 +656,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 +693,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 +704,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 +736,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 }}
|
||||
@@ -579,6 +754,8 @@ jobs:
|
||||
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||
@@ -589,11 +766,15 @@ jobs:
|
||||
-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 }}
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
|
||||
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
@@ -613,22 +794,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 +803,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 +861,103 @@ 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`
|
||||
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
|
||||
|
||||
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
2
.gitignore
vendored
@@ -512,5 +512,3 @@ nul
|
||||
/*.deb
|
||||
/*.dmg
|
||||
/*.AppImage
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
- [x] 从桌面、托盘、IPC、组件库进入设置时,都会落到同一个设置窗口
|
||||
- [x] 设置已打开时再次触发设置入口,只会聚焦已有窗口,不会切换成关闭
|
||||
- [x] 设置窗口始终拥有独立任务栏图标,不受“桌面主窗口在任务栏显示图标”开关影响
|
||||
- [x] 点击“回到 Windows”后,只隐藏或最小化桌面主窗口,设置窗口保持可见
|
||||
- [x] 启用滑入滑出动画后,只有主窗口参与动画,设置窗口不参与
|
||||
- [x] 点击设置窗口关闭按钮后会真实关闭;再次打开时创建新的居中窗口
|
||||
@@ -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** 新窗口按参考屏幕居中显示
|
||||
@@ -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 相关的测试
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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、主窗口、通知动画正常。
|
||||
@@ -1,29 +0,0 @@
|
||||
# Launcher Coordinator And Always-On Tray Addendum
|
||||
|
||||
## Launcher-to-launcher coordination
|
||||
|
||||
- Launcher reserves startup ownership in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before it starts the host process.
|
||||
- The reserved record includes `CoordinatorPid`, `CoordinatorPipeName`, `HeartbeatAtUtc`, `PublicIpcConnected`, `ShellStatus`, and `ReservedBeforeHostStart`.
|
||||
- Only the active coordinator may call `Process.Start()` for the host. Secondary Launchers attach to the coordinator pipe and request desktop activation or status.
|
||||
- If the coordinator heartbeat is newer than `10s` and the coordinator pid is alive, a new Launcher must not take over.
|
||||
- If the coordinator is stale, the next Launcher may take over the same pending attempt instead of creating a second host attempt.
|
||||
- Normal launches probe Host Public IPC first. If a host is already running, Launcher activates that instance and exits without starting another host.
|
||||
|
||||
## Finer shell status
|
||||
|
||||
- Public shell IPC exposes `GetShellStatusAsync()`, `ActivateMainWindowWithStatusAsync()`, `EnsureTrayReadyAsync()`, and `EnsureTaskbarEntryAsync()`.
|
||||
- `PublicShellStatus` separates process, shell state, main-window visibility, tray health, taskbar-entry health, and Public IPC readiness.
|
||||
- Launcher success/failure details must include coordinator pid, attempt id, host pid, Public IPC status, tray state, and taskbar usability when available.
|
||||
|
||||
## Always-on tray and taskbar repair
|
||||
|
||||
- The tray icon and menu are mandatory application-liveness indicators and are not controlled by user settings.
|
||||
- Tray watchdog starts during shell initialization and keeps running until application exit.
|
||||
- `ShowInTaskbar=true` means hidden/background states prefer `MinimizedToTaskbar`; it never disables the tray.
|
||||
- `ShowInTaskbar=false` is the only mode that may enter pure `TrayOnly`, and only after `TrayReady`.
|
||||
- When taskbar entry is requested but missing, shell repair recreates or shows the main window minimized with `ShowInTaskbar=true` while keeping the tray visible.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
- Unit tests cover active coordinator rejection, stale heartbeat takeover, and host-pid assignment after a reserved attempt.
|
||||
- Manual QA still needs multi-process Launcher concurrency and real tray loss simulation on Windows.
|
||||
@@ -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、主窗口入场、通知位置与动画正常。
|
||||
@@ -1,37 +0,0 @@
|
||||
# Launcher Slow-Startup And Startup Visual Addendum
|
||||
|
||||
## New startup timing contract
|
||||
|
||||
- `30s` is a soft timeout, not a failure threshold.
|
||||
- After `30s`, if the desktop process is still alive or Public IPC is connected, Launcher must stay in a waiting state and must not start another host process.
|
||||
- `120s` is the hard timeout.
|
||||
- Before returning `desktop_not_visible`, Launcher must attempt one foreground recovery through `ActivateMainWindowAsync()`.
|
||||
|
||||
## Startup attempt de-duplication
|
||||
|
||||
- Launcher persists the current startup attempt in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json`.
|
||||
- A second Launcher process must attach to a live pending attempt instead of calling `Process.Start()` again.
|
||||
- Closing the splash window does not cancel startup; it transitions the attempt into detached waiting and preserves recovery state for the next Launcher run.
|
||||
|
||||
## Startup visual modes
|
||||
|
||||
- `EnableSlideTransition = true` forces `StartupVisualMode.SlideSplash` and automatically disables fade.
|
||||
- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`.
|
||||
- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`.
|
||||
|
||||
## UX safeguards
|
||||
|
||||
- If the host process is still alive at failure time, the failure dialog must prefer:
|
||||
- `Activate`
|
||||
- `Wait`
|
||||
- `Open Logs`
|
||||
- `Exit`
|
||||
- Retry is only valid when Launcher is not about to create a duplicate desktop process.
|
||||
|
||||
## Launcher coordinator guard
|
||||
|
||||
- Startup attempts are now reserved before host launch, so concurrent Launchers cannot all reach `Process.Start()`.
|
||||
- A live coordinator is identified by `CoordinatorPid`, `CoordinatorPipeName`, and a heartbeat newer than `10s`.
|
||||
- Secondary Launchers send `activate-desktop` or `attach` to the coordinator pipe and then exit with the coordinator status.
|
||||
- If Host Public IPC is already available during a normal launch, Launcher activates the existing desktop and does not start a new host process.
|
||||
- Public shell status now reports tray readiness and taskbar-entry usability separately, allowing Launcher to distinguish "running but hidden" from "not recoverable".
|
||||
@@ -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] 补充规格与版本同步说明文档。
|
||||
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
|
||||
@@ -1,17 +0,0 @@
|
||||
# Tray Menu Shutdown Addendum
|
||||
|
||||
## Requirements
|
||||
|
||||
- Tray menu `Exit App` must commit an irreversible host shutdown request.
|
||||
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
|
||||
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
|
||||
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
|
||||
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
|
||||
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
|
||||
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
|
||||
- Repeated tray clicks during shutdown are ignored and logged.
|
||||
- Repeated component-library clicks focus the existing window instead of opening duplicates.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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 回路
|
||||
@@ -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 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。
|
||||
@@ -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 壳层适配器
|
||||
- [ ] 为故障、心跳、降级与恢复补齐端到端测试
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 行为
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,8 +46,8 @@ public sealed class DesktopShellHost : IDesktopShellHost
|
||||
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Exit += (_, _) => _performExitCleanup();
|
||||
_startActivationListener();
|
||||
_createAndAssignMainWindow(desktop);
|
||||
_startActivationListener();
|
||||
}
|
||||
|
||||
_startWeatherRefresh();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
@@ -6,11 +5,7 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
@@ -18,15 +13,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);
|
||||
}
|
||||
|
||||
@@ -34,624 +20,291 @@ 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 (context.IsDebugMode)
|
||||
{
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
|
||||
// 调试模式下不自动启动正常流程,由开发者通过调试窗口控制
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理各界面的预览命令
|
||||
if (HandlePreviewCommand(context, desktop))
|
||||
{
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// apply-update 模式:显示 UpdateWindow,执行增量更新 + 插件升级
|
||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 先显示窗口,再启动后台任务
|
||||
var updateWindow = new UpdateWindow();
|
||||
updateWindow.Show();
|
||||
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var splashWindow = CreateSplashWindow();
|
||||
// 正常启动流程
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
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());
|
||||
|
||||
// 先显示 Splash 窗口,确保应用程序不会立即退出
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.Show();
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||
|
||||
// 启动协调器流程
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, coordinator, splashWindow);
|
||||
}
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理界面预览命令
|
||||
/// </summary>
|
||||
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
switch (context.Command.ToLowerInvariant())
|
||||
var command = context.Command.ToLowerInvariant();
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "preview-splash":
|
||||
{
|
||||
Logger.Info("Preview command: splash.");
|
||||
var splashWindow = CreateSplashWindow();
|
||||
Console.WriteLine("[Launcher] Preview mode: SplashWindow");
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.SetDebugMode(true);
|
||||
splashWindow.Show();
|
||||
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
||||
return true;
|
||||
}
|
||||
|
||||
case "preview-error":
|
||||
{
|
||||
Logger.Info("Preview command: error.");
|
||||
Console.WriteLine("[Launcher] Preview mode: ErrorWindow");
|
||||
var errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
|
||||
errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
|
||||
errorWindow.Show();
|
||||
_ = WaitForWindowCloseAsync(desktop, errorWindow);
|
||||
return true;
|
||||
}
|
||||
|
||||
case "preview-update":
|
||||
{
|
||||
Logger.Info("Preview command: update.");
|
||||
Console.WriteLine("[Launcher] Preview mode: UpdateWindow");
|
||||
var updateWindow = new UpdateWindow();
|
||||
updateWindow.SetDebugMode(true);
|
||||
updateWindow.Show();
|
||||
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
|
||||
return true;
|
||||
}
|
||||
|
||||
case "preview-oobe":
|
||||
{
|
||||
Logger.Info("Preview command: oobe.");
|
||||
Console.WriteLine("[Launcher] Preview mode: OobeWindow");
|
||||
var oobeWindow = new OobeWindow();
|
||||
oobeWindow.Show();
|
||||
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
|
||||
return true;
|
||||
}
|
||||
|
||||
case "preview-debug":
|
||||
{
|
||||
Logger.Info("Preview command: debug window.");
|
||||
Console.WriteLine("[Launcher] Preview mode: DevDebugWindow");
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.Resolve();
|
||||
var window = new SplashWindow(preferences.Mode);
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 Splash 窗口预览
|
||||
/// </summary>
|
||||
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 messages = new[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
|
||||
var reporter = (ISplashStageReporter)window;
|
||||
|
||||
for (var i = 0; i < stages.Length; i++)
|
||||
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
{
|
||||
reporter.Report(stages[i], messages[i]);
|
||||
await Task.Delay(800).ConfigureAwait(false);
|
||||
await Task.Delay(800);
|
||||
}
|
||||
|
||||
await Task.Delay(5000).ConfigureAwait(false);
|
||||
|
||||
// 等待5秒后自动关闭
|
||||
await Task.Delay(5000);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 Update 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
|
||||
{
|
||||
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
|
||||
|
||||
for (var i = 0; i < stages.Length; i++)
|
||||
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
{
|
||||
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
|
||||
await Task.Delay(600).ConfigureAwait(false);
|
||||
window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
|
||||
await Task.Delay(600);
|
||||
}
|
||||
|
||||
|
||||
window.ReportComplete(true, null);
|
||||
await Task.Delay(3000).ConfigureAwait(false);
|
||||
|
||||
// 等待3秒后自动关闭
|
||||
await Task.Delay(3000);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
|
||||
string GetStageName(string stage) => stage switch
|
||||
{
|
||||
"verify" => "验证",
|
||||
"extract" => "解压",
|
||||
"apply" => "应用",
|
||||
"plugins" => "升级插件",
|
||||
"cleanup" => "清理",
|
||||
_ => stage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 OOBE 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
|
||||
{
|
||||
try
|
||||
{
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
Logger.Info("OOBE preview completed by user.");
|
||||
// 等待用户点击开始按钮
|
||||
await window.WaitForEnterAsync();
|
||||
Console.WriteLine("[Launcher] OOBE preview completed by user");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("OOBE preview failed.", ex);
|
||||
Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
|
||||
}
|
||||
|
||||
|
||||
// 用户点击后关闭应用程序
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待窗口关闭
|
||||
/// </summary>
|
||||
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
|
||||
{
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
window.Closed += (_, _) => tcs.TrySetResult();
|
||||
await tcs.Task.ConfigureAwait(false);
|
||||
var tcs = new TaskCompletionSource();
|
||||
window.Closed += (s, e) => tcs.TrySetResult();
|
||||
await tcs.Task;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
|
||||
private static async Task RunCoordinatorWithSplashAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
LauncherFlowCoordinator coordinator,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
SplashWindow? currentSplashWindow = splashWindow;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
|
||||
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
coordinatorPipeName,
|
||||
out var reservedAttempt,
|
||||
out var activeCoordinatorAttempt))
|
||||
ErrorWindow? errorWindow = null;
|
||||
|
||||
try
|
||||
{
|
||||
result = await AttachToExistingCoordinatorAsync(
|
||||
context,
|
||||
currentSplashWindow,
|
||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
||||
|
||||
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
return;
|
||||
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
HandleCoordinatorRequestAsync,
|
||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
||||
coordinatorServer.Start();
|
||||
|
||||
while (true)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获异常并显示错误窗口
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"启动器发生错误: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
|
||||
Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
|
||||
|
||||
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
new PluginInstallerService(),
|
||||
startupAttemptRegistry,
|
||||
coordinatorServer);
|
||||
|
||||
result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
if (result.Success ||
|
||||
result.Code == "host_not_found" ||
|
||||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
if (failureAction == ErrorWindowResult.Exit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (failureAction == ErrorWindowResult.ActivateExisting &&
|
||||
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
|
||||
{
|
||||
result = new LauncherResult
|
||||
try
|
||||
{
|
||||
// 安全关闭 Splash 窗口
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception closeEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Error closing splash window: {closeEx.Message}");
|
||||
}
|
||||
|
||||
// 创建并显示错误窗口
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage($"启动器发生错误:\n{ex.Message}\n\n请检查应用安装是否完整,或尝试重新安装。");
|
||||
errorWindow.Show();
|
||||
Console.WriteLine("[Launcher] ErrorWindow shown successfully");
|
||||
}
|
||||
catch (Exception windowEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Failed to show ErrorWindow: {windowEx.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// 如果错误窗口成功显示,等待它关闭
|
||||
if (errorWindow != null)
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_requested",
|
||||
Message = "Launcher activated the existing desktop instance.",
|
||||
Details = result.Details
|
||||
};
|
||||
break;
|
||||
try
|
||||
{
|
||||
// 等待用户选择或窗口关闭
|
||||
var errorResult = await errorWindow.WaitForChoiceAsync();
|
||||
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
|
||||
}
|
||||
catch (Exception waitEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Error waiting for ErrorWindow: {waitEx.Message}");
|
||||
// 如果等待失败,至少给用户5秒时间看到错误信息
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 错误窗口未能显示,等待5秒让用户看到控制台输出
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
}
|
||||
catch (Exception uiEx)
|
||||
{
|
||||
// 最后的兜底:记录到控制台
|
||||
Console.Error.WriteLine($"[Launcher] Critical error in UI thread: {uiEx.Message}");
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
|
||||
currentSplashWindow = CreateSplashWindow();
|
||||
currentSplashWindow.Show();
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).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<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
CommandContext context,
|
||||
SplashWindow? splashWindow,
|
||||
StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
var reporter = splashWindow as ISplashStageReporter;
|
||||
reporter?.Report("activation", "Connecting to the active launcher...");
|
||||
|
||||
if (activeCoordinatorAttempt is not null &&
|
||||
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
|
||||
{
|
||||
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
|
||||
? LauncherCoordinatorCommands.Attach
|
||||
: LauncherCoordinatorCommands.ActivateDesktop;
|
||||
var request = new LauncherCoordinatorRequest
|
||||
{
|
||||
Command = command,
|
||||
LaunchSource = context.LaunchSource,
|
||||
SuccessPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context)
|
||||
};
|
||||
|
||||
var response = await new LauncherCoordinatorIpcClient()
|
||||
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
reporter?.Report("activation", response.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = response.Accepted ||
|
||||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
||||
Message = success && !response.Accepted
|
||||
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
||||
: response.Message,
|
||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
reporter?.Report("activation", activation.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = activation.Accepted
|
||||
? "existing_host_activated"
|
||||
: success
|
||||
? "existing_host_startup_pending"
|
||||
: "existing_host_activation_failed",
|
||||
Message = success && !activation.Accepted
|
||||
? "Existing desktop process is still starting; Launcher attached without starting another process."
|
||||
: activation.Message,
|
||||
Details = BuildCoordinatorResultDetails(null, activation)
|
||||
};
|
||||
}
|
||||
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "launcher_coordinator_unavailable",
|
||||
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
|
||||
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
|
||||
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
|
||||
LauncherCoordinatorRequest request,
|
||||
LauncherCoordinatorStatus status)
|
||||
{
|
||||
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
|
||||
{
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = activation.Accepted,
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
|
||||
{
|
||||
return new LauncherCoordinatorStatus
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
HostPid = attempt.HostPid,
|
||||
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
|
||||
LaunchSource = attempt.LaunchSource,
|
||||
SuccessPolicy = attempt.SuccessPolicy,
|
||||
LastObservedStage = attempt.LastObservedStage,
|
||||
LastObservedMessage = attempt.LastObservedMessage,
|
||||
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
|
||||
State = attempt.State.ToString(),
|
||||
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
|
||||
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
|
||||
Succeeded = attempt.State == StartupAttemptState.Succeeded,
|
||||
UpdatedAtUtc = attempt.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(
|
||||
PublicShellActivationResult? activation,
|
||||
LauncherCoordinatorStatus? status)
|
||||
{
|
||||
if (activation is { Accepted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status is { Completed: false, HostProcessAlive: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var shellStatus = activation?.Status;
|
||||
if (shellStatus is null || !shellStatus.PublicIpcReady)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shellStatus.MainWindowOpened ||
|
||||
!shellStatus.DesktopVisible ||
|
||||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||
LauncherCoordinatorStatus? status,
|
||||
PublicShellActivationResult? activation)
|
||||
{
|
||||
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
|
||||
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
|
||||
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
|
||||
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
|
||||
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
|
||||
["startupState"] = status?.State ?? string.Empty,
|
||||
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
|
||||
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
|
||||
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
|
||||
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
|
||||
};
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
|
||||
{
|
||||
if (splashWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
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<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
|
||||
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
|
||||
hostProcessAliveValue;
|
||||
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var parsedPid)
|
||||
? parsedPid
|
||||
: (int?)null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
if (hostProcessAlive)
|
||||
{
|
||||
errorWindow.ConfigureForRunningHostFailure(hostPid);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorWindow.ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
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 ErrorWindowResult.Exit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return activation?.Accepted == true;
|
||||
}
|
||||
|
||||
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
if (!ipcClient.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
|
||||
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != activationTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await activationTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
|
||||
/// </summary>
|
||||
private static async Task RunApplyUpdateWithWindowAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
@@ -668,7 +321,8 @@ public partial class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
|
||||
// 1. 应用增量更新
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
|
||||
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
@@ -676,47 +330,58 @@ public partial class App : Application
|
||||
errorMessage = updateResult.Message;
|
||||
}
|
||||
|
||||
// 2. 应用待处理的插件升级
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 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}");
|
||||
// 插件升级失败不阻断整体流程,仅记录到控制台
|
||||
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理旧版本
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
|
||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
|
||||
deploymentLocator.CleanupDestroyedDeployments();
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
if (success)
|
||||
{
|
||||
// 成功:停留 1.5 秒让用户看到"更新完成"
|
||||
await Task.Delay(1500);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 失败:停留 5 秒让用户看到错误信息
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
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(LauncherCoordinatorRequest))]
|
||||
[JsonSerializable(typeof(LauncherCoordinatorResponse))]
|
||||
[JsonSerializable(typeof(LauncherCoordinatorStatus))]
|
||||
[JsonSerializable(typeof(PublicShellStatus))]
|
||||
[JsonSerializable(typeof(PublicTrayStatus))]
|
||||
[JsonSerializable(typeof(PublicTaskbarStatus))]
|
||||
[JsonSerializable(typeof(PublicShellActivationResult))]
|
||||
[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;
|
||||
@@ -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-----
|
||||
@@ -4,19 +4,6 @@ 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; }
|
||||
@@ -33,8 +20,6 @@ internal sealed class CommandContext
|
||||
Options.ContainsKey("plugins-dir") &&
|
||||
Options.ContainsKey("result");
|
||||
|
||||
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||
@@ -43,20 +28,6 @@ internal sealed class CommandContext
|
||||
Options.ContainsKey("debug") ||
|
||||
System.Diagnostics.Debugger.IsAttached;
|
||||
|
||||
public bool IsPreviewCommand =>
|
||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGuiCommand =>
|
||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsMaintenanceCommand =>
|
||||
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string? ExplicitAppRoot => GetOption("app-root");
|
||||
|
||||
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
|
||||
{
|
||||
Command = command;
|
||||
@@ -91,45 +62,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 +79,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];
|
||||
|
||||
@@ -56,11 +56,7 @@
|
||||
<!-- 允许 IL 警告 -->
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
|
||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
|
||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<!-- FluentAvaloniaUI 需要:启用反射序列化(AOT 兼容模式) -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<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>
|
||||
<!-- 应用程序图标 -->
|
||||
@@ -18,7 +18,6 @@
|
||||
<ItemGroup>
|
||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,7 +34,6 @@
|
||||
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- Avalonia 资源文件 -->
|
||||
<AvaloniaResource Include="Assets\logo.ico" />
|
||||
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal static class LauncherCoordinatorCommands
|
||||
{
|
||||
public const string Attach = "attach";
|
||||
public const string ActivateDesktop = "activate-desktop";
|
||||
public const string GetStatus = "get-status";
|
||||
}
|
||||
|
||||
internal sealed class LauncherCoordinatorRequest
|
||||
{
|
||||
[JsonPropertyName("requestId")]
|
||||
public string RequestId { get; init; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[JsonPropertyName("command")]
|
||||
public string Command { get; init; } = LauncherCoordinatorCommands.Attach;
|
||||
|
||||
[JsonPropertyName("launcherPid")]
|
||||
public int LauncherPid { get; init; } = Environment.ProcessId;
|
||||
|
||||
[JsonPropertyName("launchSource")]
|
||||
public string LaunchSource { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("successPolicy")]
|
||||
public string SuccessPolicy { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class LauncherCoordinatorResponse
|
||||
{
|
||||
[JsonPropertyName("accepted")]
|
||||
public bool Accepted { get; init; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public LauncherCoordinatorStatus? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("activationResult")]
|
||||
public PublicShellActivationResult? ActivationResult { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class LauncherCoordinatorStatus
|
||||
{
|
||||
[JsonPropertyName("attemptId")]
|
||||
public string AttemptId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("coordinatorPid")]
|
||||
public int CoordinatorPid { get; init; } = Environment.ProcessId;
|
||||
|
||||
[JsonPropertyName("hostPid")]
|
||||
public int HostPid { get; init; }
|
||||
|
||||
[JsonPropertyName("hostProcessAlive")]
|
||||
public bool HostProcessAlive { get; init; }
|
||||
|
||||
[JsonPropertyName("launchSource")]
|
||||
public string LaunchSource { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("successPolicy")]
|
||||
public string SuccessPolicy { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("lastObservedStage")]
|
||||
public StartupStage LastObservedStage { get; init; } = StartupStage.Initializing;
|
||||
|
||||
[JsonPropertyName("lastObservedMessage")]
|
||||
public string LastObservedMessage { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("publicIpcConnected")]
|
||||
public bool PublicIpcConnected { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string State { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("softTimeoutShown")]
|
||||
public bool SoftTimeoutShown { get; init; }
|
||||
|
||||
[JsonPropertyName("completed")]
|
||||
public bool Completed { get; init; }
|
||||
|
||||
[JsonPropertyName("succeeded")]
|
||||
public bool Succeeded { get; init; }
|
||||
|
||||
[JsonPropertyName("shellStatus")]
|
||||
public PublicShellStatus? ShellStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAtUtc")]
|
||||
public DateTimeOffset UpdatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal enum StartupAttemptState
|
||||
{
|
||||
Pending,
|
||||
SoftTimeout,
|
||||
DetachedWaiting,
|
||||
Succeeded,
|
||||
Failed,
|
||||
WaitingForShell
|
||||
}
|
||||
|
||||
internal sealed class StartupAttemptRecord
|
||||
{
|
||||
[JsonPropertyName("attemptId")]
|
||||
public string AttemptId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[JsonPropertyName("hostPid")]
|
||||
public int HostPid { get; set; }
|
||||
|
||||
[JsonPropertyName("coordinatorPid")]
|
||||
public int CoordinatorPid { get; set; }
|
||||
|
||||
[JsonPropertyName("coordinatorPipeName")]
|
||||
public string CoordinatorPipeName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startedAtUtc")]
|
||||
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("updatedAtUtc")]
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("heartbeatAtUtc")]
|
||||
public DateTimeOffset HeartbeatAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("launchSource")]
|
||||
public string LaunchSource { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("successPolicy")]
|
||||
public string SuccessPolicy { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("lastObservedStage")]
|
||||
public StartupStage LastObservedStage { get; set; } = StartupStage.Initializing;
|
||||
|
||||
[JsonPropertyName("lastObservedMessage")]
|
||||
public string LastObservedMessage { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ipcConnected")]
|
||||
public bool IpcConnected { get; set; }
|
||||
|
||||
[JsonPropertyName("publicIpcConnected")]
|
||||
public bool PublicIpcConnected { get; set; }
|
||||
|
||||
[JsonPropertyName("shellStatus")]
|
||||
public string ShellStatus { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reservedBeforeHostStart")]
|
||||
public bool ReservedBeforeHostStart { get; set; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public StartupAttemptState State { get; set; } = StartupAttemptState.Pending;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -10,60 +10,32 @@ 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>"}'.");
|
||||
|
||||
try
|
||||
// 处理遗留插件安装命令
|
||||
if (commandContext.IsLegacyPluginInstall)
|
||||
{
|
||||
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);
|
||||
}
|
||||
var installer = new PluginInstallerService();
|
||||
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
|
||||
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// 处理其他 CLI 命令 (update, plugin, rollback 等)
|
||||
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Logger.Error("Launcher failed before GUI flow completed.", ex);
|
||||
|
||||
var result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launcher",
|
||||
Code = "launcher_bootstrap_failed",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.ToString(),
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["command"] = commandContext.Command,
|
||||
["subCommand"] = commandContext.SubCommand,
|
||||
["launchSource"] = commandContext.LaunchSource,
|
||||
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
|
||||
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
|
||||
["isElevated"] = execution.IsElevated.ToString(),
|
||||
["userSid"] = execution.UserSid ?? string.Empty,
|
||||
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? string.Empty
|
||||
}
|
||||
};
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(commandContext.GetOption("result"), result).ConfigureAwait(false);
|
||||
return 1;
|
||||
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -166,10 +164,7 @@ internal static class Commands
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
var launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
|
||||
var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir)
|
||||
? launcherDir
|
||||
: AppContext.BaseDirectory);
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
|
||||
// 发布版结构:Launcher 和 app-* 目录在同一目录
|
||||
// 检查当前目录是否有 app-* 子目录(发布版)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
@@ -18,283 +17,83 @@ 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()
|
||||
{
|
||||
// 使用新的灵活定位器
|
||||
var options = new HostDiscoveryOptions
|
||||
{
|
||||
ExecutableName = "LanMountainDesktop",
|
||||
PreferDevModeConfig = true,
|
||||
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
|
||||
AdditionalSearchPaths = new List<string>
|
||||
{
|
||||
// 可以通过配置文件或环境变量添加更多路径
|
||||
"${AppRoot}",
|
||||
"${AppRoot}/..",
|
||||
"${BaseDirectory}/../..",
|
||||
}
|
||||
};
|
||||
|
||||
var locator = new FlexibleHostLocator(_appRoot, options);
|
||||
var result = locator.ResolveHostExecutablePath();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// 回退到旧逻辑(作为备选)
|
||||
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))
|
||||
{
|
||||
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
|
||||
{
|
||||
searchedPaths.Add(fullSavedPath);
|
||||
if (File.Exists(fullSavedPath))
|
||||
{
|
||||
source = "debug_saved_custom_path";
|
||||
return fullSavedPath;
|
||||
}
|
||||
|
||||
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{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 bool TryNormalizeSavedDebugPath(string savedPath, out string fullSavedPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
fullSavedPath = Path.GetFullPath(savedPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
fullSavedPath = string.Empty;
|
||||
Logger.Warn($"Saved launcher debug host path is invalid and cannot be normalized; falling back to development paths. Path='{savedPath}'; Error='{ex.Message}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传统的主程序路径解析(作为备选)
|
||||
/// </summary>
|
||||
private string? ResolveHostExecutablePathLegacy()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
|
||||
// 1. 棣栧厛鏌ユ壘 app-{version} 鐩綍锛堢敓浜х幆澧冿級
|
||||
// 1. 首先查找 app-{version} 目录(生产环境)
|
||||
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
@@ -305,12 +104,14 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
|
||||
var inRoot = Path.Combine(_appRoot, executable);
|
||||
if (File.Exists(inRoot))
|
||||
{
|
||||
return inRoot;
|
||||
}
|
||||
|
||||
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
|
||||
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
var inParent = Path.Combine(parent, executable);
|
||||
if (File.Exists(inParent))
|
||||
@@ -318,23 +119,17 @@ internal sealed class DeploymentLocator
|
||||
return inParent;
|
||||
}
|
||||
|
||||
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
|
||||
// 4. 开发模式:如果启用了开发模式,优先使用保存的自定义路径
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
// 4.1 首先检查保存的自定义路径
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
|
||||
{
|
||||
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
|
||||
File.Exists(fullSavedPath))
|
||||
{
|
||||
return fullSavedPath;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
|
||||
{
|
||||
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
|
||||
}
|
||||
return savedCustomPath;
|
||||
}
|
||||
|
||||
// 4.2 扫描开发路径
|
||||
var devPath = ScanDevelopmentPaths(executable);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
@@ -342,7 +137,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
|
||||
// 5. 开发模式:查找主程序项目的输出目录
|
||||
var devPaths = GetDevelopmentPaths(executable);
|
||||
foreach (var devPath in devPaths)
|
||||
{
|
||||
@@ -356,21 +151,21 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
|
||||
/// 扫描开发路径(开发模式)
|
||||
/// </summary>
|
||||
private static string? ScanDevelopmentPaths(string executable)
|
||||
{
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 浠?Launcher 椤圭洰杩愯
|
||||
// 从 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 鐩綍
|
||||
// dev-test 目录
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
@@ -386,22 +181,25 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
|
||||
/// 获取开发环境可能的主程序路径
|
||||
/// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
// 获取 Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 可能的开发目录结构
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 浠?Launcher 椤圭洰杩愯锛?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// 从 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
|
||||
// 从解决方案根目录运行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 浠?dev-test 鐩綍杩愯
|
||||
// 从 dev-test 目录运行
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
@@ -435,155 +233,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);
|
||||
@@ -608,17 +286,37 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </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
|
||||
var deploymentDir = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var versionFile = Path.Combine(deploymentDir, "version.json");
|
||||
if (File.Exists(versionFile))
|
||||
{
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate"
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFile);
|
||||
var info = JsonSerializer.Deserialize<AppVersionInfo>(json);
|
||||
if (info is not null)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略读取失败,回退到默认值
|
||||
}
|
||||
}
|
||||
: resolved;
|
||||
}
|
||||
|
||||
// 回退:从目录名解析版本,使用默认开发代号
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate" // 默认开发代号
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,67 +4,50 @@ 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;
|
||||
/// 灵活的主程序定位器
|
||||
/// </summary>
|
||||
internal sealed class FlexibleHostLocator
|
||||
{
|
||||
private readonly HostDiscoveryOptions _options;
|
||||
private readonly string _appRoot;
|
||||
|
||||
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
||||
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
_options = options ?? new HostDiscoveryOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析主程序可执行文件路径
|
||||
/// </summary>
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
var executable = GetExecutableName();
|
||||
var searchContext = new SearchContext
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
_options = options ?? new HostDiscoveryOptions();
|
||||
_deploymentLocator = new DeploymentLocator(appRoot);
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析主程序可执行文件路径
|
||||
/// </summary>
|
||||
public string? ResolveHostExecutablePath()
|
||||
// 2. 搜索部署目录(app-*)- 生产环境标准路径
|
||||
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
||||
{
|
||||
var executable = GetExecutableName();
|
||||
var searchContext = new SearchContext
|
||||
{
|
||||
ExecutableName = executable,
|
||||
AppRoot = _appRoot,
|
||||
Options = _options
|
||||
};
|
||||
return deploymentPath;
|
||||
}
|
||||
|
||||
// ========== 第一阶段:标准路径查找(快速路径)==========
|
||||
|
||||
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
|
||||
var envPath = GetPathFromEnvironment();
|
||||
if (!string.IsNullOrWhiteSpace(envPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(envPath, "environment variable");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
|
||||
// 2. 使用 DeploymentLocator(ClassIsland 风格的简洁查询 - 优先)
|
||||
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
|
||||
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var deploymentExePath = Path.Combine(deploymentDir, executable);
|
||||
if (File.Exists(deploymentExePath))
|
||||
{
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
|
||||
return deploymentExePath;
|
||||
}
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
|
||||
}
|
||||
|
||||
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
|
||||
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
|
||||
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
||||
{
|
||||
return deploymentPath;
|
||||
}
|
||||
|
||||
// 4. 检查 Launcher 同级目录(便携模式)
|
||||
// 3. 检查 Launcher 同级目录(便携模式)
|
||||
var portablePath = SearchPortableLocation(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(portablePath))
|
||||
{
|
||||
@@ -73,7 +56,7 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
|
||||
|
||||
// 5. 检查配置文件中的路径 - 用户自定义配置
|
||||
// 4. 检查配置文件中的路径 - 用户自定义配置
|
||||
var configPath = GetPathFromConfigFile();
|
||||
if (!string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
@@ -88,7 +71,7 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
return nearbyPath;
|
||||
}
|
||||
|
||||
// 7. 开发模式:检查保存的自定义路径
|
||||
// 6. 开发模式:检查保存的自定义路径
|
||||
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
@@ -99,21 +82,21 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 搜索标准开发路径
|
||||
// 7. 搜索标准开发路径
|
||||
var devPath = SearchDevelopmentPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
|
||||
// 9. 搜索额外的配置路径
|
||||
// 8. 搜索额外的配置路径
|
||||
var additionalPath = SearchAdditionalPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(additionalPath))
|
||||
{
|
||||
return additionalPath;
|
||||
}
|
||||
|
||||
// 10. 递归搜索(如果启用)
|
||||
// 9. 递归搜索(如果启用)
|
||||
if (_options.RecursiveSearch)
|
||||
{
|
||||
var recursivePath = SearchRecursively(searchContext);
|
||||
@@ -159,7 +142,7 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
|
||||
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json);
|
||||
if (config?.HostPath != null && File.Exists(config.HostPath))
|
||||
{
|
||||
return config.HostPath;
|
||||
@@ -560,11 +543,6 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -622,13 +600,13 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
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; }
|
||||
/// <summary>
|
||||
/// 发现配置文件
|
||||
/// </summary>
|
||||
private class HostDiscoveryConfig
|
||||
{
|
||||
public string? HostPath { get; set; }
|
||||
public List<string>? AdditionalPaths { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed record HostLaunchPlan(
|
||||
string HostPath,
|
||||
string PackageRoot,
|
||||
string WorkingDirectory,
|
||||
IReadOnlyList<string> Arguments,
|
||||
IReadOnlyDictionary<string, string> EnvironmentVariables,
|
||||
AppVersionInfo VersionInfo);
|
||||
|
||||
internal static class HostLaunchPlanBuilder
|
||||
{
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
public static HostLaunchPlan Build(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
HostResolutionResult resolution)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(deploymentLocator);
|
||||
ArgumentNullException.ThrowIfNull(resolution);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
||||
{
|
||||
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
|
||||
}
|
||||
|
||||
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
|
||||
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
|
||||
var versionInfo = deploymentLocator.GetVersionInfo();
|
||||
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo);
|
||||
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
|
||||
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
|
||||
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
|
||||
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
|
||||
};
|
||||
|
||||
return new HostLaunchPlan(
|
||||
hostPath,
|
||||
packageRoot,
|
||||
Directory.Exists(packageRoot)
|
||||
? packageRoot
|
||||
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
|
||||
arguments,
|
||||
environment,
|
||||
versionInfo);
|
||||
}
|
||||
|
||||
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
|
||||
{
|
||||
return string.Join(" ", arguments.Select(QuoteArgument));
|
||||
}
|
||||
|
||||
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
|
||||
{
|
||||
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
|
||||
? AppContext.BaseDirectory
|
||||
: Path.GetFullPath(appRoot);
|
||||
|
||||
var hostDirectory = Path.GetDirectoryName(hostPath);
|
||||
if (hostDirectory is not null &&
|
||||
Directory.Exists(fullAppRoot) &&
|
||||
IsAppDeploymentDirectory(hostDirectory) &&
|
||||
IsParentOf(fullAppRoot, hostDirectory))
|
||||
{
|
||||
return fullAppRoot;
|
||||
}
|
||||
|
||||
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fullAppRoot;
|
||||
}
|
||||
|
||||
return hostDirectory ?? fullAppRoot;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildForwardedArguments(
|
||||
CommandContext context,
|
||||
string packageRoot,
|
||||
AppVersionInfo versionInfo)
|
||||
{
|
||||
var arguments = new List<string>();
|
||||
|
||||
for (var index = 0; index < context.RawArgs.Count; index++)
|
||||
{
|
||||
var arg = context.RawArgs[index];
|
||||
|
||||
if (index == 0 &&
|
||||
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index == 1 &&
|
||||
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var key = arg[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
key = key[..equalsIndex];
|
||||
}
|
||||
|
||||
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (equalsIndex < 0 &&
|
||||
index + 1 < context.RawArgs.Count &&
|
||||
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
arguments.Add(arg);
|
||||
}
|
||||
|
||||
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
|
||||
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private static bool IsAppDeploymentDirectory(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
|
||||
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsParentOf(string parent, string child)
|
||||
{
|
||||
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return childPath.StartsWith(
|
||||
parentPath + Path.DirectorySeparatorChar,
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||
|
||||
internal sealed class LauncherCoordinatorIpcClient
|
||||
{
|
||||
private const int LengthPrefixSize = 4;
|
||||
private const int MaxPayloadLength = 1024 * 1024;
|
||||
|
||||
public async Task<LauncherCoordinatorResponse?> SendAsync(
|
||||
string pipeName,
|
||||
LauncherCoordinatorRequest request,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pipeName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var timeoutCts = new CancellationTokenSource(timeout);
|
||||
try
|
||||
{
|
||||
await using var client = new NamedPipeClientStream(
|
||||
".",
|
||||
pipeName,
|
||||
PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
await WriteRequestAsync(client, request, timeoutCts.Token).ConfigureAwait(false);
|
||||
return await ReadResponseAsync(client, timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to send launcher coordinator IPC request: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteRequestAsync(
|
||||
Stream stream,
|
||||
LauncherCoordinatorRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.LauncherCoordinatorRequest);
|
||||
var payload = Encoding.UTF8.GetBytes(json);
|
||||
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
|
||||
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorResponse?> ReadResponseAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lengthBuffer = new byte[LengthPrefixSize];
|
||||
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
|
||||
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = new byte[payloadLength];
|
||||
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(
|
||||
Encoding.UTF8.GetString(payload),
|
||||
AppJsonContext.Default.LauncherCoordinatorResponse);
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactAsync(
|
||||
Stream stream,
|
||||
byte[] buffer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalRead = 0;
|
||||
while (totalRead < buffer.Length)
|
||||
{
|
||||
var read = await stream
|
||||
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.IO.Pipes;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||
|
||||
internal sealed class LauncherCoordinatorIpcServer : IDisposable
|
||||
{
|
||||
private const int LengthPrefixSize = 4;
|
||||
private const int MaxPayloadLength = 1024 * 1024;
|
||||
private readonly string _pipeName;
|
||||
private readonly Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> _requestHandler;
|
||||
private readonly Action<LauncherCoordinatorStatus> _heartbeatHandler;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly object _statusGate = new();
|
||||
private LauncherCoordinatorStatus _status;
|
||||
private Task? _listenTask;
|
||||
private Task? _heartbeatTask;
|
||||
|
||||
public LauncherCoordinatorIpcServer(
|
||||
string pipeName,
|
||||
LauncherCoordinatorStatus initialStatus,
|
||||
Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> requestHandler,
|
||||
Action<LauncherCoordinatorStatus> heartbeatHandler)
|
||||
{
|
||||
_pipeName = pipeName;
|
||||
_status = initialStatus;
|
||||
_requestHandler = requestHandler;
|
||||
_heartbeatHandler = heartbeatHandler;
|
||||
}
|
||||
|
||||
public static string CreatePipeName()
|
||||
{
|
||||
var seed = $"{Environment.UserName}:{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}";
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed.ToLowerInvariant()));
|
||||
return $"LanMountainDesktop_Launcher_Coordinator_{Convert.ToHexString(bytes[..8])}";
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_listenTask ??= Task.Run(ListenLoopAsync);
|
||||
_heartbeatTask ??= Task.Run(HeartbeatLoopAsync);
|
||||
}
|
||||
|
||||
public LauncherCoordinatorStatus GetStatus()
|
||||
{
|
||||
lock (_statusGate)
|
||||
{
|
||||
return _status;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateStatus(LauncherCoordinatorStatus status)
|
||||
{
|
||||
lock (_statusGate)
|
||||
{
|
||||
_status = status;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(1));
|
||||
_heartbeatTask?.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task ListenLoopAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
NamedPipeServerStream? server = null;
|
||||
try
|
||||
{
|
||||
server = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.InOut,
|
||||
8,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await server.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
|
||||
var connectedServer = server;
|
||||
_ = Task.Run(() => HandleConnectionAsync(connectedServer, _cts.Token), _cts.Token);
|
||||
server = null;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Launcher coordinator IPC listener failed: {ex.Message}");
|
||||
try
|
||||
{
|
||||
await Task.Delay(250, _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
server?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatLoopAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_heartbeatHandler(GetStatus());
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Launcher coordinator heartbeat failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleConnectionAsync(NamedPipeServerStream server, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = await ReadRequestAsync(server, cancellationToken).ConfigureAwait(false);
|
||||
var status = GetStatus();
|
||||
var response = request is null
|
||||
? new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = false,
|
||||
Code = "invalid_request",
|
||||
Message = "Launcher coordinator request was invalid.",
|
||||
Status = status
|
||||
}
|
||||
: await _requestHandler(request, status).ConfigureAwait(false);
|
||||
|
||||
await WriteResponseAsync(server, response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Launcher coordinator IPC request failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
server.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorRequest?> ReadRequestAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lengthBuffer = new byte[LengthPrefixSize];
|
||||
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
|
||||
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = new byte[payloadLength];
|
||||
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(
|
||||
Encoding.UTF8.GetString(payload),
|
||||
AppJsonContext.Default.LauncherCoordinatorRequest);
|
||||
}
|
||||
|
||||
private static async Task WriteResponseAsync(
|
||||
Stream stream,
|
||||
LauncherCoordinatorResponse response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(response, AppJsonContext.Default.LauncherCoordinatorResponse);
|
||||
var payload = Encoding.UTF8.GetBytes(json);
|
||||
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
|
||||
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactAsync(
|
||||
Stream stream,
|
||||
byte[] buffer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalRead = 0;
|
||||
while (totalRead < buffer.Length)
|
||||
{
|
||||
var read = await stream
|
||||
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public class LauncherIpcServer : IDisposable
|
||||
|
||||
// 3. 反序列化并回调
|
||||
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
|
||||
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||
if (message is not null)
|
||||
{
|
||||
_onProgress(message);
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
|
||||
|
||||
internal static class LauncherDebugSettingsStore
|
||||
{
|
||||
private const string DevModeFileName = "dev-mode.flag";
|
||||
private const string CustomHostPathFileName = "custom-host-path.txt";
|
||||
private const string LegacyDevModeFileName = "devmode.config";
|
||||
private const string LegacyCustomHostPathFileName = "custom-host-path.config";
|
||||
|
||||
internal static string? ConfigBaseDirectoryOverride { get; set; }
|
||||
|
||||
public static string ConfigBaseDirectory => ConfigBaseDirectoryOverride ?? ResolveConfigBaseDirectory();
|
||||
|
||||
public static LauncherDebugSettings Load()
|
||||
{
|
||||
return new LauncherDebugSettings(
|
||||
LoadDevModeState(),
|
||||
LoadCustomHostPath());
|
||||
}
|
||||
|
||||
public static bool IsDevModeEnabled() => Load().DevModeEnabled;
|
||||
|
||||
public static string? GetSavedCustomHostPath() => Load().CustomHostPath;
|
||||
|
||||
public static void Save(LauncherDebugSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ConfigBaseDirectory);
|
||||
File.WriteAllText(GetPath(DevModeFileName), settings.DevModeEnabled.ToString());
|
||||
File.WriteAllText(GetPath(CustomHostPathFileName), settings.CustomHostPath ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to save launcher debug settings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void SaveDevModeState(bool enabled)
|
||||
{
|
||||
var current = Load();
|
||||
Save(current with { DevModeEnabled = enabled });
|
||||
}
|
||||
|
||||
public static void SaveCustomHostPath(string? customHostPath)
|
||||
{
|
||||
var current = Load();
|
||||
Save(current with { CustomHostPath = customHostPath });
|
||||
}
|
||||
|
||||
private static bool LoadDevModeState()
|
||||
{
|
||||
var newValue = TryReadText(GetPath(DevModeFileName));
|
||||
if (!string.IsNullOrWhiteSpace(newValue))
|
||||
{
|
||||
return TryParseDevMode(newValue);
|
||||
}
|
||||
|
||||
var legacyValue = TryReadText(GetPath(LegacyDevModeFileName));
|
||||
return !string.IsNullOrWhiteSpace(legacyValue) && TryParseDevMode(legacyValue);
|
||||
}
|
||||
|
||||
private static string? LoadCustomHostPath()
|
||||
{
|
||||
var newValue = TryReadText(GetPath(CustomHostPathFileName));
|
||||
if (!string.IsNullOrWhiteSpace(newValue))
|
||||
{
|
||||
return newValue.Trim();
|
||||
}
|
||||
|
||||
var legacyValue = TryReadText(GetPath(LegacyCustomHostPathFileName));
|
||||
return string.IsNullOrWhiteSpace(legacyValue) ? null : legacyValue.Trim();
|
||||
}
|
||||
|
||||
private static bool TryParseDevMode(string value)
|
||||
{
|
||||
var normalized = value.Trim();
|
||||
return normalized == "1" ||
|
||||
normalized.Equals("true", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("on", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? TryReadText(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to read launcher debug setting '{path}': {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetPath(string fileName) => Path.Combine(ConfigBaseDirectory, fileName);
|
||||
|
||||
private static string ResolveConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, ".launcher");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 // 便携版
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,221 +1,34 @@
|
||||
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");
|
||||
// 将 OOBE 状态文件存储在用户可写的 LocalApplicationData 目录中,
|
||||
// 而不是安装目录(Program Files 下普通用户没有写入权限)。
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
var stateDir = Path.Combine(appDataDir, ".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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,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,46 +51,6 @@ 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);
|
||||
@@ -118,7 +73,7 @@ internal sealed class PluginInstallerService
|
||||
using var stream = entries[0].Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
||||
if (manifest == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class StartupAttemptRegistry
|
||||
{
|
||||
private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _statePath;
|
||||
private readonly string _mutexName;
|
||||
private string? _ownedAttemptId;
|
||||
|
||||
public StartupAttemptRegistry()
|
||||
: this(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
".launcher",
|
||||
"state",
|
||||
"startup-attempt.json"))
|
||||
{
|
||||
}
|
||||
|
||||
internal StartupAttemptRegistry(string statePath)
|
||||
{
|
||||
_statePath = statePath;
|
||||
_mutexName = $"LanMountainDesktop.Launcher.StartupAttempt.{ComputePathHash(statePath)}";
|
||||
}
|
||||
|
||||
public StartupAttemptRecord StartOwnedAttempt(
|
||||
int hostPid,
|
||||
string launchSource,
|
||||
string successPolicy,
|
||||
StartupStage stage,
|
||||
string? message)
|
||||
{
|
||||
var record = new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = Guid.NewGuid().ToString("N"),
|
||||
HostPid = hostPid,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
LaunchSource = launchSource,
|
||||
SuccessPolicy = successPolicy,
|
||||
LastObservedStage = stage,
|
||||
LastObservedMessage = message ?? string.Empty,
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
HeartbeatAtUtc = DateTimeOffset.UtcNow,
|
||||
State = StartupAttemptState.Pending
|
||||
};
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
SaveUnsafe(record);
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
});
|
||||
|
||||
return Clone(record);
|
||||
}
|
||||
|
||||
public bool TryReserveCoordinator(
|
||||
string launchSource,
|
||||
string successPolicy,
|
||||
string coordinatorPipeName,
|
||||
out StartupAttemptRecord reservedAttempt,
|
||||
out StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
StartupAttemptRecord? reserved = null;
|
||||
StartupAttemptRecord? active = null;
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var existing = LoadUnsafe();
|
||||
if (existing is not null && IsCoordinatorLive(existing))
|
||||
{
|
||||
active = Clone(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing is not null && IsRecoverableCoordinatorAttempt(existing))
|
||||
{
|
||||
existing.CoordinatorPid = Environment.ProcessId;
|
||||
existing.CoordinatorPipeName = coordinatorPipeName;
|
||||
existing.HeartbeatAtUtc = DateTimeOffset.UtcNow;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
if (existing.HostPid <= 0)
|
||||
{
|
||||
existing.ReservedBeforeHostStart = true;
|
||||
}
|
||||
|
||||
if (existing.State == StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
existing.State = StartupAttemptState.SoftTimeout;
|
||||
}
|
||||
|
||||
_ownedAttemptId = existing.AttemptId;
|
||||
SaveUnsafe(existing);
|
||||
reserved = Clone(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = Guid.NewGuid().ToString("N"),
|
||||
HostPid = 0,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
CoordinatorPipeName = coordinatorPipeName,
|
||||
LaunchSource = launchSource,
|
||||
SuccessPolicy = successPolicy,
|
||||
LastObservedStage = StartupStage.Initializing,
|
||||
LastObservedMessage = "Launcher coordinator reserved startup ownership.",
|
||||
StartedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
HeartbeatAtUtc = now,
|
||||
ReservedBeforeHostStart = true,
|
||||
State = StartupAttemptState.Pending
|
||||
};
|
||||
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
SaveUnsafe(record);
|
||||
reserved = Clone(record);
|
||||
});
|
||||
|
||||
reservedAttempt = reserved ?? new StartupAttemptRecord();
|
||||
activeCoordinatorAttempt = active;
|
||||
return reserved is not null;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? GetOwnedAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null && string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetLiveCoordinatorAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null && IsCoordinatorLive(record))
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetLatestAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null)
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord AssignOwnedHostProcess(
|
||||
int hostPid,
|
||||
StartupStage stage,
|
||||
string? message)
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.HostPid = hostPid;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
record.ReservedBeforeHostStart = false;
|
||||
result = Clone(record);
|
||||
});
|
||||
|
||||
return result ?? StartOwnedAttempt(
|
||||
hostPid,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
stage,
|
||||
message);
|
||||
}
|
||||
|
||||
public bool AdoptAttempt(string attemptId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attemptId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var adopted = false;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null || !string.Equals(record.AttemptId, attemptId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsAttachable(record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
if (record.State == StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
record.State = StartupAttemptState.SoftTimeout;
|
||||
}
|
||||
|
||||
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SaveUnsafe(record);
|
||||
adopted = true;
|
||||
});
|
||||
|
||||
return adopted;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetAttachableAttempt(string launchSource, string successPolicy)
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null ||
|
||||
!IsAttachable(record) ||
|
||||
!string.Equals(record.LaunchSource, launchSource, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(record.SuccessPolicy, successPolicy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result = Clone(record);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void MarkOwnedIpcConnected()
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.IpcConnected = true;
|
||||
record.PublicIpcConnected = true;
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? string.Empty;
|
||||
if (ipcConnected)
|
||||
{
|
||||
record.IpcConnected = true;
|
||||
record.PublicIpcConnected = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateOwnedCoordinatorHeartbeat(LauncherCoordinatorStatus status)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.CoordinatorPid = Environment.ProcessId;
|
||||
record.HeartbeatAtUtc = DateTimeOffset.UtcNow;
|
||||
record.LastObservedStage = status.LastObservedStage;
|
||||
record.LastObservedMessage = status.LastObservedMessage;
|
||||
record.IpcConnected = status.PublicIpcConnected;
|
||||
record.PublicIpcConnected = status.PublicIpcConnected;
|
||||
record.ShellStatus = status.ShellStatus?.ShellState ?? status.State;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedSoftTimeout(string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.SoftTimeout;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedWaitingForShell(string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
record.State = StartupAttemptState.WaitingForShell;
|
||||
}
|
||||
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedDetachedWaiting()
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout)
|
||||
{
|
||||
record.State = StartupAttemptState.DetachedWaiting;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedSucceeded(StartupStage stage, string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.Succeeded;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedFailed(StartupStage stage, string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.Failed;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateOwned(Action<StartupAttemptRecord> update)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
update(record);
|
||||
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SaveUnsafe(record);
|
||||
});
|
||||
}
|
||||
|
||||
private void ExecuteWithLock(Action action)
|
||||
{
|
||||
using var mutex = new Mutex(false, _mutexName);
|
||||
var hasHandle = false;
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
hasHandle = true;
|
||||
}
|
||||
|
||||
if (!hasHandle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hasHandle)
|
||||
{
|
||||
mutex.ReleaseMutex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StartupAttemptRecord? LoadUnsafe()
|
||||
{
|
||||
if (!File.Exists(_statePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_statePath);
|
||||
return JsonSerializer.Deserialize<StartupAttemptRecord>(json, SerializerOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveUnsafe(StartupAttemptRecord record)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_statePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions));
|
||||
}
|
||||
|
||||
private static bool IsAttachable(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.HostPid, out _);
|
||||
}
|
||||
|
||||
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.HostPid <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.HostPid, out _);
|
||||
}
|
||||
|
||||
private static bool IsCoordinatorLive(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.CoordinatorPid <= 0 ||
|
||||
string.IsNullOrWhiteSpace(record.CoordinatorPipeName) ||
|
||||
DateTimeOffset.UtcNow - record.HeartbeatAtUtc > CoordinatorHeartbeatTimeout)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.CoordinatorPid, out _);
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId, out Process? process)
|
||||
{
|
||||
process = null;
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
process?.Dispose();
|
||||
process = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputePathHash(string statePath)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(statePath.ToLowerInvariant()));
|
||||
return Convert.ToHexString(bytes[..8]);
|
||||
}
|
||||
|
||||
private static StartupAttemptRecord Clone(StartupAttemptRecord record)
|
||||
{
|
||||
return new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = record.AttemptId,
|
||||
HostPid = record.HostPid,
|
||||
CoordinatorPid = record.CoordinatorPid,
|
||||
CoordinatorPipeName = record.CoordinatorPipeName,
|
||||
StartedAtUtc = record.StartedAtUtc,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||
HeartbeatAtUtc = record.HeartbeatAtUtc,
|
||||
LaunchSource = record.LaunchSource,
|
||||
SuccessPolicy = record.SuccessPolicy,
|
||||
LastObservedStage = record.LastObservedStage,
|
||||
LastObservedMessage = record.LastObservedMessage,
|
||||
IpcConnected = record.IpcConnected,
|
||||
PublicIpcConnected = record.PublicIpcConnected,
|
||||
ShellStatus = record.ShellStatus,
|
||||
ReservedBeforeHostStart = record.ReservedBeforeHostStart,
|
||||
State = record.State
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,41 +5,52 @@ using Avalonia.Platform.Storage;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误调试窗口 - 开发人员专用调试设置
|
||||
/// </summary>
|
||||
public partial class ErrorDebugWindow : Window
|
||||
{
|
||||
private string? _selectedHostPath;
|
||||
private bool _isInitialized;
|
||||
private bool _isInitialized = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled { get; private set; }
|
||||
|
||||
public bool WasAccepted { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 选择的主程序路径
|
||||
/// </summary>
|
||||
public string? SelectedHostPath => _selectedHostPath;
|
||||
|
||||
public ErrorDebugWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
|
||||
// 延迟到窗口加载完成后再初始化组件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
}
|
||||
|
||||
public ErrorDebugWindow(bool devModeEnabled, string? initialPath)
|
||||
: this()
|
||||
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
|
||||
{
|
||||
IsDevModeEnabled = devModeEnabled;
|
||||
_selectedHostPath = initialPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
|
||||
InitializeComponents();
|
||||
|
||||
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||
|
||||
// 设置初始值(在视觉树准备好后)
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
{
|
||||
devModeToggle.IsChecked = IsDevModeEnabled;
|
||||
}
|
||||
@@ -49,72 +60,113 @@ public partial class ErrorDebugWindow : Window
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||
// 开发模式开关
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
{
|
||||
devModeToggle.IsCheckedChanged += (_, _) =>
|
||||
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!");
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
|
||||
// 浏览按钮
|
||||
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!");
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("OkButton") is { } okButton)
|
||||
// 确定按钮
|
||||
var okButton = this.FindControl<Button>("OkButton");
|
||||
if (okButton is not null)
|
||||
{
|
||||
okButton.Click += (_, _) =>
|
||||
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) =>
|
||||
{
|
||||
WasAccepted = true;
|
||||
// 取消时恢复原始状态
|
||||
IsDevModeEnabled = false;
|
||||
_selectedHostPath = null;
|
||||
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
|
||||
Close();
|
||||
};
|
||||
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("CancelButton") is { } cancelButton)
|
||||
else
|
||||
{
|
||||
cancelButton.Click += (_, _) => Close();
|
||||
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;
|
||||
}
|
||||
if (storageProvider is null) return;
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Select LanMountainDesktop host executable",
|
||||
Title = "选择阑山桌面主程序",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("Executable")
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType("可执行文件")
|
||||
{
|
||||
Patterns = OperatingSystem.IsWindows()
|
||||
? ["*.exe"]
|
||||
: ["*"]
|
||||
? new[] { "*.exe" }
|
||||
: new[] { "*" }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||
if (result.Count <= 0)
|
||||
if (result.Count > 0)
|
||||
{
|
||||
return;
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新路径显示
|
||||
/// </summary>
|
||||
private void UpdatePathDisplay(string? path)
|
||||
{
|
||||
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
|
||||
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
||||
if (pathTextBlock is not null)
|
||||
{
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,97 +3,94 @@
|
||||
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="LanMountain Desktop"
|
||||
Width="560"
|
||||
Height="320"
|
||||
Title="阑山桌面"
|
||||
Width="520"
|
||||
Height="280"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="#111318"
|
||||
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"
|
||||
ColumnDefinitions="Auto,*">
|
||||
<!-- 主内容区域:左侧图标 + 右侧文字 -->
|
||||
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- 左侧:错误图标(可点击进入调试模式) -->
|
||||
<Border x:Name="ErrorIconBorder"
|
||||
Grid.Column="0"
|
||||
Width="52"
|
||||
Height="52"
|
||||
Margin="0,4,18,0"
|
||||
Background="#2B161A"
|
||||
CornerRadius="26"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0,4,16,0"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="24"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock Text="!"
|
||||
<TextBlock Text=""
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
Foreground="#FFB4AB"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="10">
|
||||
|
||||
<!-- 右侧:标题 + 内容 -->
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<!-- 标题 -->
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="Launcher could not confirm startup"
|
||||
FontSize="20"
|
||||
Text="启动失败"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#F6F7FB"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<TextBlock x:Name="ErrorMessageText"
|
||||
Text="LanMountain Desktop did not reach the expected startup state."
|
||||
Text="找不到阑山桌面应用程序。"
|
||||
FontSize="14"
|
||||
Foreground="#D2D7E1"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="22" />
|
||||
|
||||
LineHeight="20"/>
|
||||
|
||||
<!-- 建议信息 -->
|
||||
<TextBlock x:Name="SuggestionText"
|
||||
Text="You can inspect logs, retry when the old process is gone, or reactivate the current instance."
|
||||
Text="请确保应用程序已正确安装,或尝试重新安装。"
|
||||
FontSize="13"
|
||||
Foreground="#9BA5B7"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="20" />
|
||||
LineHeight="18"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:按钮区域 -->
|
||||
<Border Grid.Row="1"
|
||||
Padding="24,16"
|
||||
Background="#171A21">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="OpenLogButton"
|
||||
Grid.Column="0"
|
||||
Content="Open Logs"
|
||||
MinWidth="108"
|
||||
Height="34"
|
||||
HorizontalAlignment="Left" />
|
||||
|
||||
<Button x:Name="SecondaryActionButton"
|
||||
Grid.Column="1"
|
||||
Content="Wait"
|
||||
MinWidth="108"
|
||||
Height="34"
|
||||
IsVisible="False" />
|
||||
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="24,16">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="8">
|
||||
<Button x:Name="ExitButton"
|
||||
Grid.Column="2"
|
||||
Content="Exit"
|
||||
MinWidth="90"
|
||||
Height="34" />
|
||||
|
||||
<Button x:Name="PrimaryActionButton"
|
||||
Grid.Column="3"
|
||||
Content="Retry"
|
||||
MinWidth="108"
|
||||
Height="34" />
|
||||
</Grid>
|
||||
Content="退出"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="RetryButton"
|
||||
Content="重试"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -1,314 +1,370 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
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 readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _iconClickCount;
|
||||
private bool _isDebugMode;
|
||||
private bool _devModeEnabled;
|
||||
private bool _isDebugMode = false;
|
||||
private string? _customHostPath;
|
||||
private ErrorWindowResult _primaryAction = ErrorWindowResult.Retry;
|
||||
private ErrorWindowResult? _secondaryAction;
|
||||
private bool _devModeEnabled;
|
||||
|
||||
public ErrorWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 先加载保存的状态
|
||||
_devModeEnabled = LoadDevModeStateInternal();
|
||||
_customHostPath = LoadCustomHostPathInternal();
|
||||
|
||||
Loaded += OnWindowLoaded;
|
||||
Closed += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
ConfigureForGenericFailure(allowRetry: true);
|
||||
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
|
||||
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");
|
||||
|
||||
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!");
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorWindow] Components initialization completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置错误消息
|
||||
/// </summary>
|
||||
public void SetErrorMessage(string message)
|
||||
{
|
||||
if (this.FindControl<TextBlock>("ErrorMessageText") is { } errorText)
|
||||
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
|
||||
if (errorText is not null)
|
||||
{
|
||||
errorText.Text = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
_isDebugMode = isDebugMode;
|
||||
if (isDebugMode && this.FindControl<TextBlock>("TitleText") is { } titleText)
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
if (titleText is not null && isDebugMode)
|
||||
{
|
||||
titleText.Text = "[Debug] Launcher error";
|
||||
titleText.Text = "[调试模式] 错误页面";
|
||||
}
|
||||
}
|
||||
|
||||
public void ConfigureForHostNotFound()
|
||||
{
|
||||
ApplyActionLayout(
|
||||
title: "Launcher could not find the desktop executable",
|
||||
suggestion: "Pick another executable in debug mode, inspect logs, or retry after fixing the deployment path.",
|
||||
primaryLabel: "Retry",
|
||||
primaryAction: ErrorWindowResult.Retry,
|
||||
secondaryLabel: null,
|
||||
secondaryAction: null);
|
||||
}
|
||||
|
||||
public void ConfigureForGenericFailure(bool allowRetry)
|
||||
{
|
||||
ApplyActionLayout(
|
||||
title: "Launcher could not confirm startup",
|
||||
suggestion: allowRetry
|
||||
? "Inspect logs, then retry once the previous startup attempt has fully finished."
|
||||
: "Inspect logs or exit. Launcher will avoid creating another desktop process while the old one is still running.",
|
||||
primaryLabel: allowRetry ? "Retry" : "Activate",
|
||||
primaryAction: allowRetry ? ErrorWindowResult.Retry : ErrorWindowResult.ActivateExisting,
|
||||
secondaryLabel: allowRetry ? null : "Wait",
|
||||
secondaryAction: allowRetry ? null : ErrorWindowResult.ContinueWaiting);
|
||||
}
|
||||
|
||||
public void ConfigureForRunningHostFailure(int? hostPid)
|
||||
{
|
||||
var pidHint = hostPid is > 0 ? $" Current host PID: {hostPid}." : string.Empty;
|
||||
ApplyActionLayout(
|
||||
title: "Startup is still pending",
|
||||
suggestion: $"The desktop process is still running, so Launcher will not start a second instance.{pidHint}",
|
||||
primaryLabel: "Activate",
|
||||
primaryAction: ErrorWindowResult.ActivateExisting,
|
||||
secondaryLabel: "Wait",
|
||||
secondaryAction: ErrorWindowResult.ContinueWaiting);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户选择的主程序路径
|
||||
/// </summary>
|
||||
public string? GetCustomHostPath() => _customHostPath;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||
|
||||
public Task<ErrorWindowResult> WaitForChoiceAsync() => _completionSource.Task;
|
||||
|
||||
public static bool CheckDevModeEnabled() => LoadDevModeStateInternal();
|
||||
|
||||
public static string? GetSavedCustomHostPath() => LoadCustomHostPathInternal();
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
/// <summary>
|
||||
/// 等待用户选择
|
||||
/// </summary>
|
||||
public Task<ErrorWindowResult> WaitForChoiceAsync()
|
||||
{
|
||||
if (this.FindControl<Border>("ErrorIconBorder") is { } errorIconBorder)
|
||||
{
|
||||
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("PrimaryActionButton") is { } primaryActionButton)
|
||||
{
|
||||
primaryActionButton.Click += OnPrimaryActionClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("SecondaryActionButton") is { } secondaryActionButton)
|
||||
{
|
||||
secondaryActionButton.Click += OnSecondaryActionClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("ExitButton") is { } exitButton)
|
||||
{
|
||||
exitButton.Click += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("OpenLogButton") is { } openLogButton)
|
||||
{
|
||||
openLogButton.Click += OnOpenLogClick;
|
||||
}
|
||||
return _completionSource.Task;
|
||||
}
|
||||
|
||||
private void ApplyActionLayout(
|
||||
string title,
|
||||
string suggestion,
|
||||
string primaryLabel,
|
||||
ErrorWindowResult primaryAction,
|
||||
string? secondaryLabel,
|
||||
ErrorWindowResult? secondaryAction)
|
||||
{
|
||||
_primaryAction = primaryAction;
|
||||
_secondaryAction = secondaryAction;
|
||||
|
||||
if (this.FindControl<TextBlock>("TitleText") is { } titleText && !_isDebugMode)
|
||||
{
|
||||
titleText.Text = title;
|
||||
}
|
||||
|
||||
if (this.FindControl<TextBlock>("SuggestionText") is { } suggestionText)
|
||||
{
|
||||
suggestionText.Text = suggestion;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("PrimaryActionButton") is { } primaryButton)
|
||||
{
|
||||
primaryButton.Content = primaryLabel;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("SecondaryActionButton") is { } secondaryButton)
|
||||
{
|
||||
secondaryButton.IsVisible = !string.IsNullOrWhiteSpace(secondaryLabel);
|
||||
secondaryButton.Content = secondaryLabel ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPrimaryActionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(_primaryAction);
|
||||
}
|
||||
|
||||
private void OnSecondaryActionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(_secondaryAction ?? ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
private void OnErrorIconClick(object? sender, PointerPressedEventArgs e)
|
||||
/// <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 += (_, _) =>
|
||||
// 订阅调试窗口关闭事件
|
||||
debugWindow.Closed += (s, e) =>
|
||||
{
|
||||
if (!debugWindow.WasAccepted)
|
||||
{
|
||||
_isDebugMode = false;
|
||||
_iconClickCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||
_customHostPath = debugWindow.SelectedHostPath;
|
||||
|
||||
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
|
||||
// 保存开发模式状态和自定义路径
|
||||
SaveDevModeStateInternal(_devModeEnabled);
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
|
||||
// 如果启用了开发模式且没有选择路径,自动扫描
|
||||
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
|
||||
{
|
||||
ScanDevPaths();
|
||||
// 扫描到路径后也保存
|
||||
if (!string.IsNullOrEmpty(_customHostPath))
|
||||
{
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
}
|
||||
}
|
||||
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _customHostPath));
|
||||
|
||||
_isDebugMode = false;
|
||||
_iconClickCount = 0;
|
||||
};
|
||||
|
||||
await debugWindow.ShowDialog(this);
|
||||
}
|
||||
|
||||
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFilePath = Logger.GetLogFilePath();
|
||||
if (!string.IsNullOrWhiteSpace(logFilePath) && File.Exists(logFilePath))
|
||||
{
|
||||
OpenPath(logFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
var logDirectory = !string.IsNullOrWhiteSpace(logFilePath)
|
||||
? Path.GetDirectoryName(logFilePath)
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(logDirectory) && Directory.Exists(logDirectory))
|
||||
{
|
||||
OpenPath(logDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
var configDirectory = GetConfigBaseDirectory();
|
||||
if (Directory.Exists(configDirectory))
|
||||
{
|
||||
OpenPath(configDirectory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[ErrorWindow] Failed to open log path: {ex}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描开发路径
|
||||
/// </summary>
|
||||
private void ScanDevPaths()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var candidatePaths = new[]
|
||||
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, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var candidate in candidatePaths.Select(Path.GetFullPath).Distinct())
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
if (File.Exists(path))
|
||||
{
|
||||
_customHostPath = candidate;
|
||||
_customHostPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenPath(string path)
|
||||
/// <summary>
|
||||
/// 保存开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveDevModeStateInternal(bool enabled)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
var devModeFile = GetDevModeFilePath();
|
||||
var dir = Path.GetDirectoryName(devModeFile);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
catch (Exception ex)
|
||||
{
|
||||
Process.Start("open", path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", path);
|
||||
Console.Error.WriteLine($"Failed to save dev mode state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
return LauncherDebugSettingsStore.ConfigBaseDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static bool LoadDevModeStateInternal()
|
||||
{
|
||||
return LauncherDebugSettingsStore.IsDevModeEnabled();
|
||||
try
|
||||
{
|
||||
var devModeFile = GetDevModeFilePath();
|
||||
if (File.Exists(devModeFile))
|
||||
{
|
||||
var content = File.ReadAllText(devModeFile).Trim();
|
||||
return content == "1";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load dev mode state: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取开发模式状态文件路径
|
||||
/// </summary>
|
||||
private static string GetDevModeFilePath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "devmode.config");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveCustomHostPathInternal(string? path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hostPathFile = GetCustomHostPathFilePath();
|
||||
var dir = Path.GetDirectoryName(hostPathFile);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
File.WriteAllText(hostPathFile, path ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to save custom host path: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static string? LoadCustomHostPathInternal()
|
||||
{
|
||||
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
|
||||
try
|
||||
{
|
||||
var hostPathFile = GetCustomHostPathFilePath();
|
||||
if (File.Exists(hostPathFile))
|
||||
{
|
||||
var content = File.ReadAllText(hostPathFile).Trim();
|
||||
// 验证路径是否仍然有效
|
||||
if (!string.IsNullOrEmpty(content) && File.Exists(content))
|
||||
{
|
||||
return content;
|
||||
}
|
||||
// 路径已失效,清理配置文件
|
||||
try
|
||||
{
|
||||
File.Delete(hostPathFile);
|
||||
Console.WriteLine("Custom host path is no longer valid, cleared saved path.");
|
||||
}
|
||||
catch (Exception clearEx)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to clear invalid host path: {clearEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load custom host path: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取自定义主程序路径文件路径
|
||||
/// </summary>
|
||||
private static string GetCustomHostPathFilePath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "custom-host-path.config");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public enum ErrorWindowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 重试
|
||||
/// </summary>
|
||||
Retry,
|
||||
Exit,
|
||||
ActivateExisting,
|
||||
ContinueWaiting
|
||||
|
||||
/// <summary>
|
||||
/// 退出
|
||||
/// </summary>
|
||||
Exit
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
FontSize="20"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="CurrentItemName"
|
||||
Grid.Row="0" Grid.Column="1"
|
||||
Text="Initializing..."
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
|
||||
<TextBlock x:Name="CurrentItemDescription"
|
||||
Grid.Row="1" Grid.Column="1"
|
||||
Text="Preparing components"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
|
||||
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
|
||||
<ProgressBar x:Name="CurrentItemProgress"
|
||||
Height="4"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
CornerRadius="2"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="8">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="Loading Items"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
<TextBlock x:Name="CompletedCountText"
|
||||
Grid.Column="1"
|
||||
Text="0"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,0,4,0"/>
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="Done"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Margin="8,0,8,8">
|
||||
<ItemsControl x:Name="LoadingItemsList">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="views:LoadingItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
Margin="4,3"
|
||||
Opacity="{Binding Opacity}">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding StatusIcon}"
|
||||
FontSize="14"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{Binding StatusColor}"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Name}"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding ProgressText}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<Border Grid.Column="3"
|
||||
Background="{Binding TypeBackground}"
|
||||
CornerRadius="4"
|
||||
Padding="6,2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding TypeLabel}"
|
||||
FontSize="11"
|
||||
Foreground="{Binding TypeForeground}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="ErrorPanel"
|
||||
Grid.Row="2"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="12,10"
|
||||
Margin="16,0,16,12"
|
||||
IsVisible="False">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text=""
|
||||
FontSize="16"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="ErrorText"
|
||||
Grid.Column="1"
|
||||
Text="An error occurred while loading."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="VersionText"
|
||||
Grid.Column="0"
|
||||
Text="v1.0.0"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="DetailsButton"
|
||||
Content="Details"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="CancelButton"
|
||||
Content="Cancel"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -21,11 +21,7 @@
|
||||
<views:OobeWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid x:Name="ContentGrid"
|
||||
Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform Y="24" />
|
||||
</Grid.RenderTransform>
|
||||
<Grid x:Name="ContentGrid">
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Margin="48" RowDefinitions="*,Auto">
|
||||
<!-- 中央内容区域 -->
|
||||
|
||||
@@ -9,18 +9,26 @@ using Avalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// OOBE(首次使用体验)窗口 - 欢迎页面
|
||||
/// </summary>
|
||||
public partial class OobeWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
private bool _isTransitioning;
|
||||
private bool _isTransitioning = false;
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
|
||||
// 延迟到窗口加载完成后再初始化
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
|
||||
@@ -37,29 +45,31 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件 - 播放入场动画
|
||||
/// </summary>
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
|
||||
await PlayEntranceAnimationAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放入场动画
|
||||
/// </summary>
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取内容元素
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接返回
|
||||
return;
|
||||
}
|
||||
|
||||
var 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),
|
||||
@@ -79,6 +89,7 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
};
|
||||
|
||||
// 创建向上滑动动画
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
@@ -87,7 +98,7 @@ public partial class OobeWindow : Window
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, offset) },
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
@@ -98,9 +109,9 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
};
|
||||
|
||||
await Task.WhenAll(
|
||||
fadeInAnimation.RunAsync(contentGrid),
|
||||
slideUpAnimation.RunAsync(translateTransform));
|
||||
// 应用动画
|
||||
await fadeInAnimation.RunAsync(contentGrid);
|
||||
await slideUpAnimation.RunAsync(contentGrid);
|
||||
|
||||
Console.WriteLine("[OobeWindow] Entrance animation completed");
|
||||
}
|
||||
@@ -110,21 +121,27 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户点击开始按钮
|
||||
/// </summary>
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
/// <summary>
|
||||
/// 进入按钮点击事件
|
||||
/// </summary>
|
||||
private async void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isTransitioning) return;
|
||||
_isTransitioning = true;
|
||||
|
||||
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
|
||||
|
||||
try
|
||||
{
|
||||
// 播放退出动画
|
||||
await PlayExitAnimationAsync();
|
||||
|
||||
// 完成 OOBE
|
||||
_completionSource.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -134,6 +151,9 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放退出动画
|
||||
/// </summary>
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
try
|
||||
@@ -141,10 +161,12 @@ public partial class OobeWindow : Window
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接延迟后返回
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡出动画
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
@@ -172,11 +194,4 @@ public partial class OobeWindow : Window
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,92 +3,85 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="480"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||
x:DataType="views:SplashWindow"
|
||||
Title="LanMountain Desktop"
|
||||
Width="480"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
Background="#0B0B0B"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:SplashWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
Background="#0B0B0B">
|
||||
<Grid Grid.Row="0">
|
||||
<Grid x:Name="CompactHero"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</Grid>
|
||||
<Grid>
|
||||
<!-- 左上角:应用名称 -->
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="24,24,0,0"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
|
||||
<Grid x:Name="FullscreenHero"
|
||||
IsVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="24">
|
||||
<Border Width="240"
|
||||
Height="240"
|
||||
Background="Transparent">
|
||||
<Image Source="/Assets/logo_nightly.png"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="LanMountain Desktop"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#F6F7FB" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Padding="24,18,24,24"
|
||||
Background="Transparent">
|
||||
<Grid RowDefinitions="Auto,Auto"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Border x:Name="VersionTextBorder"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
HorizontalAlignment="Left">
|
||||
<TextBlock x:Name="VersionText"
|
||||
FontSize="11"
|
||||
Foreground="#B9C0CC"
|
||||
Text="0.0.0-dev (Administrate)" />
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="1"
|
||||
<!-- 底部区域:进度条和状态 -->
|
||||
<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="#B9C0CC"
|
||||
HorizontalAlignment="Right"
|
||||
Text="Initializing..." />
|
||||
</Grid>
|
||||
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="#F6F7FB"
|
||||
Background="#2C313D" />
|
||||
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>
|
||||
</Border>
|
||||
|
||||
<!-- 底部:进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -1,273 +1,88 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 启动画面窗口 - 简洁设计
|
||||
/// </summary>
|
||||
public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
private int _versionTextClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
|
||||
private static readonly TimeSpan SlideAnimationDuration = TimeSpan.FromMilliseconds(260);
|
||||
|
||||
private readonly StartupVisualMode _mode;
|
||||
private int _versionTextClickCount;
|
||||
private bool _isDebugModeOpened;
|
||||
private bool _isOpened;
|
||||
private bool _layoutConfigured;
|
||||
private bool _dismissed;
|
||||
private PixelPoint _targetPosition;
|
||||
private PixelPoint _slideHiddenPosition;
|
||||
private bool _isDebugModeOpened = false;
|
||||
|
||||
public SplashWindow()
|
||||
: this(StartupVisualMode.Fade)
|
||||
{
|
||||
}
|
||||
|
||||
public SplashWindow(StartupVisualMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
|
||||
// 延迟到窗口加载完成后再绑定事件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
|
||||
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
|
||||
|
||||
// 绑定版本文本点击事件(隐藏功能:点击5次打开开发者界面)
|
||||
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
|
||||
if (versionTextBorder is not null)
|
||||
{
|
||||
versionBorder.PointerPressed += OnVersionTextClick;
|
||||
versionTextBorder.PointerPressed += OnVersionTextClick;
|
||||
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isOpened)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isOpened = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
Opacity = 0d;
|
||||
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Opacity = 1d;
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
await AnimateWindowPositionAsync(_slideHiddenPosition, _targetPosition, SlideAnimationDuration, EaseOutCubic).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DismissAsync()
|
||||
{
|
||||
if (_dismissed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dismissed = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
var from = Position;
|
||||
await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
|
||||
}
|
||||
else if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (IsVisible)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
var progress = ResolveProgress(stage);
|
||||
if (progress > 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = stage;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message) &&
|
||||
this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateStatus(string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void SetVersionInfo(string version, string codename)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("VersionText") is { } versionText)
|
||||
{
|
||||
versionText.Text = $"{version} ({codename})";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
if (!isDebugMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateStatus("[Debug Mode] Splash Preview");
|
||||
}
|
||||
|
||||
private void ConfigureForVisualMode()
|
||||
{
|
||||
if (_layoutConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_layoutConfigured = true;
|
||||
var compactHero = this.FindControl<Grid>("CompactHero");
|
||||
var fullscreenHero = this.FindControl<Grid>("FullscreenHero");
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
Background = new SolidColorBrush(Color.Parse("#0B0B0B"));
|
||||
Width = 480;
|
||||
Height = 320;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
return;
|
||||
}
|
||||
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
Background = Brushes.Black;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
|
||||
var screen = Screens?.Primary ?? Screens?.All.FirstOrDefault();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scale = Math.Max(screen?.Scaling ?? 1d, 0.01d);
|
||||
|
||||
Width = workingArea.Width / scale;
|
||||
Height = workingArea.Height / scale;
|
||||
_targetPosition = new PixelPoint(workingArea.X, workingArea.Y);
|
||||
_slideHiddenPosition = new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y);
|
||||
Position = _mode == StartupVisualMode.SlideSplash
|
||||
? _slideHiddenPosition
|
||||
: _targetPosition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 版本文本点击事件 - 连续点击5次打开开发者界面(隐藏功能)
|
||||
/// </summary>
|
||||
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_isDebugModeOpened)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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 debugWindow = new ErrorDebugWindow(
|
||||
ErrorWindow.CheckDevModeEnabled(),
|
||||
ErrorWindow.GetSavedCustomHostPath())
|
||||
// 加载保存的状态
|
||||
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
|
||||
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
|
||||
|
||||
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||
};
|
||||
|
||||
debugWindow.Closed += (_, _) =>
|
||||
// 订阅窗口关闭事件以保存状态
|
||||
debugWindow.Closed += (s, e) =>
|
||||
{
|
||||
if (debugWindow.WasAccepted)
|
||||
{
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
|
||||
debugWindow.IsDevModeEnabled,
|
||||
debugWindow.SelectedHostPath));
|
||||
}
|
||||
|
||||
Console.WriteLine("[SplashWindow] Debug window closed");
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
};
|
||||
@@ -276,75 +91,160 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[SplashWindow] Failed to open debug window: {ex}");
|
||||
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AnimateOpacityAsync(double from, double to, TimeSpan duration)
|
||||
/// <summary>
|
||||
/// 更新进度和状态
|
||||
/// </summary>
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
await AnimateAsync(progress =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Opacity = from + ((to - from) * progress);
|
||||
}, duration, EaseOutCubic).ConfigureAwait(false);
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态文本
|
||||
statusText.Text = message;
|
||||
|
||||
// 根据阶段更新进度
|
||||
var progress = ResolveProgress(stage);
|
||||
if (progress > 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task AnimateWindowPositionAsync(
|
||||
PixelPoint from,
|
||||
PixelPoint to,
|
||||
TimeSpan duration,
|
||||
Func<double, double> easing)
|
||||
/// <summary>
|
||||
/// 更新进度(0-100)
|
||||
/// </summary>
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
{
|
||||
await AnimateAsync(progress =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var currentX = (int)Math.Round(from.X + ((to.X - from.X) * progress));
|
||||
var currentY = (int)Math.Round(from.Y + ((to.Y - from.Y) * progress));
|
||||
Position = new PixelPoint(currentX, currentY);
|
||||
}, duration, easing).ConfigureAwait(false);
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task AnimateAsync(Action<double> update, TimeSpan duration, Func<double, double> easing)
|
||||
/// <summary>
|
||||
/// 更新状态文本
|
||||
/// </summary>
|
||||
public void UpdateStatus(string message)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
return;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
while (stopwatch.Elapsed < duration)
|
||||
{
|
||||
var raw = stopwatch.Elapsed.TotalMilliseconds / duration.TotalMilliseconds;
|
||||
var progress = easing(Math.Clamp(raw, 0d, 1d));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(progress));
|
||||
await Task.Delay(16).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
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,
|
||||
"settings" => 25,
|
||||
"update" => 30,
|
||||
"plugins" => 50,
|
||||
"ui" => 65,
|
||||
"shell" => 80,
|
||||
"activation" => 90,
|
||||
"launch" => 70,
|
||||
"ready" => 100,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static double EaseOutCubic(double value)
|
||||
{
|
||||
var inverse = 1d - value;
|
||||
return 1d - (inverse * inverse * inverse);
|
||||
}
|
||||
|
||||
private static double EaseInCubic(double value) => value * value * value;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
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"
|
||||
d:DesignWidth="400"
|
||||
d:DesignHeight="220"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
|
||||
x:DataType="views:UpdateWindow"
|
||||
Title="阑山桌面 - 更新"
|
||||
Width="480"
|
||||
Height="320"
|
||||
Width="400"
|
||||
Height="220"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
@@ -21,88 +21,48 @@
|
||||
<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>
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
<!-- 应用名称 -->
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="阑山桌面"
|
||||
FontSize="36"
|
||||
FontWeight="Light"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Grid.Row="0"
|
||||
Margin="0,30,0,0"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
|
||||
<!-- 最小化按钮 -->
|
||||
<Button x:Name="MinimizeButton"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Background="Transparent"
|
||||
BorderThickness="0">
|
||||
<TextBlock Text=""
|
||||
FontSize="12"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<!-- 状态文本 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Row="1"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,16,0,0"
|
||||
Text="正在更新,请稍候..." />
|
||||
|
||||
<!-- 底部区域:进度条和状态 -->
|
||||
<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>
|
||||
<!-- 进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="2"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="3"
|
||||
Width="200"
|
||||
Margin="0,16,0,0"
|
||||
IsIndeterminate="True"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<TextBlock x:Name="DetailText"
|
||||
Grid.Row="3"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,12,0,24"
|
||||
Text="" />
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -12,22 +12,6 @@ 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>
|
||||
@@ -39,11 +23,11 @@ public partial class UpdateWindow : Window
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
var detailText = this.FindControl<TextBlock>("DetailText");
|
||||
|
||||
if (statusText is null || progressIndicator is null || percentText is null)
|
||||
if (statusText is null || progressIndicator is null || detailText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, PercentText={percentText != null}");
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, DetailText={detailText != null}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,13 +37,23 @@ public partial class UpdateWindow : Window
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progressPercent;
|
||||
percentText.Text = $"{progressPercent}%";
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
percentText.Text = "";
|
||||
}
|
||||
|
||||
// 根据阶段显示不同的底部提示
|
||||
detailText.Text = stage.ToLowerInvariant() switch
|
||||
{
|
||||
"verify" => "正在验证更新完整性...",
|
||||
"extract" => "正在解压更新包...",
|
||||
"apply" => "正在应用更新文件...",
|
||||
"plugins" => "正在升级插件...",
|
||||
"cleanup" => "正在清理...",
|
||||
"done" => "",
|
||||
_ => ""
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,10 +66,10 @@ public partial class UpdateWindow : Window
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
var detailText = this.FindControl<TextBlock>("DetailText");
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
|
||||
if (statusText is null || progressIndicator is null || percentText is null || titleText is null)
|
||||
if (statusText is null || progressIndicator is null || detailText is null || titleText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
|
||||
return;
|
||||
@@ -83,7 +77,7 @@ public partial class UpdateWindow : Window
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = 100;
|
||||
percentText.Text = "100%";
|
||||
detailText.Text = "";
|
||||
|
||||
if (success)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?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"/>
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public static class PluginIsolationProtocolVersion
|
||||
{
|
||||
public const string Current = "1.0";
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -1,90 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public sealed class PluginIpcClient
|
||||
{
|
||||
public PluginIpcClient(PluginIpcClientOptions options)
|
||||
{
|
||||
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
|
||||
SerializerOptions = SerializerContext.Options;
|
||||
}
|
||||
|
||||
public PluginIpcClientOptions Options { get; }
|
||||
|
||||
public JsonSerializerContext SerializerContext { get; }
|
||||
|
||||
public JsonSerializerOptions SerializerOptions { get; }
|
||||
|
||||
public PluginIpcRequestDispatcher? RequestDispatcher { get; set; }
|
||||
|
||||
public PluginIpcNotificationDispatcher? NotificationDispatcher { get; set; }
|
||||
|
||||
public Task<TResponse?> RequestAsync<TRequest, TResponse>(
|
||||
string route,
|
||||
TRequest payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
return RequestCoreAsync<TRequest, TResponse>(route, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyAsync<TPayload>(
|
||||
string route,
|
||||
TPayload payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
return NotifyCoreAsync(route, Serialize(payload), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TResponse?> RequestCoreAsync<TRequest, TResponse>(
|
||||
string route,
|
||||
TRequest payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (RequestDispatcher is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
|
||||
"Wire RequestDispatcher during host/worker transport integration.");
|
||||
}
|
||||
|
||||
var response = await RequestDispatcher(route, Serialize(payload), cancellationToken).ConfigureAwait(false);
|
||||
if (response is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return Deserialize<TResponse>(response);
|
||||
}
|
||||
|
||||
private async Task NotifyCoreAsync(string route, JsonElement? payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (NotificationDispatcher is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
|
||||
"Wire NotificationDispatcher during host/worker transport integration.");
|
||||
}
|
||||
|
||||
await NotificationDispatcher(route, payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private JsonElement Serialize<T>(T payload)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
|
||||
}
|
||||
|
||||
private T? Deserialize<T>(JsonElement? payload)
|
||||
{
|
||||
if (payload is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return payload.Value.Deserialize<T>(SerializerOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public sealed record PluginIpcClientOptions
|
||||
{
|
||||
public required string PipeName { get; init; }
|
||||
|
||||
public string ProtocolVersion { get; init; } = PluginIsolationProtocolVersion.Current;
|
||||
|
||||
public TimeSpan ConnectTimeout { get; init; } = PluginIpcConstants.DefaultConnectTimeout;
|
||||
|
||||
public TimeSpan RequestTimeout { get; init; } = PluginIpcConstants.DefaultRequestTimeout;
|
||||
|
||||
public JsonSerializerContext SerializerContext { get; init; } = PluginIsolationJsonContext.Default;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public static class PluginIpcConstants
|
||||
{
|
||||
public const string EnvironmentPluginId = "LANMOUNTAIN_PLUGIN_ID";
|
||||
public const string EnvironmentSessionId = "LANMOUNTAIN_PLUGIN_SESSION_ID";
|
||||
public const string EnvironmentHostPipeName = "LANMOUNTAIN_PLUGIN_HOST_PIPE";
|
||||
public const string EnvironmentProtocolVersion = "LANMOUNTAIN_PLUGIN_PROTOCOL_VERSION";
|
||||
public const string EnvironmentRuntimeMode = "LANMOUNTAIN_PLUGIN_RUNTIME_MODE";
|
||||
|
||||
public const string CommandLinePluginId = "--plugin-id";
|
||||
public const string CommandLineSessionId = "--session-id";
|
||||
public const string CommandLineHostPipeName = "--host-pipe-name";
|
||||
public const string CommandLineProtocolVersion = "--protocol-version";
|
||||
public const string CommandLineRuntimeMode = "--runtime-mode";
|
||||
|
||||
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
|
||||
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5);
|
||||
public static readonly TimeSpan DefaultHeartbeatTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
public const string DefaultProtocolVersion = PluginIsolationProtocolVersion.Current;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public delegate Task<JsonElement?> PluginIpcRequestDispatcher(
|
||||
string route,
|
||||
JsonElement? payload,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
public delegate Task PluginIpcNotificationDispatcher(
|
||||
string route,
|
||||
JsonElement? payload,
|
||||
CancellationToken cancellationToken);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user