mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
Compare commits
15 Commits
v0.8.5.4
...
launcher-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ffadd1a0 | ||
|
|
5b4b9f32b5 | ||
|
|
8b8c7d1e7f | ||
|
|
43c0ee6c06 | ||
|
|
403cf280bb | ||
|
|
ad3648a0b8 | ||
|
|
28f41cd27c | ||
|
|
9de93d2a4d | ||
|
|
0085c66514 | ||
|
|
d4901e436f | ||
|
|
2d9391f930 | ||
|
|
927dc8d1fd | ||
|
|
33591a0a63 | ||
|
|
001d77968f | ||
|
|
e20462ac2b |
152
.github/VERSION_SYNC_INFO.md
vendored
152
.github/VERSION_SYNC_INFO.md
vendored
@@ -1,127 +1,65 @@
|
||||
# 版本号自动同步说明
|
||||
# 版本同步说明
|
||||
|
||||
## 📋 概述
|
||||
## 目标
|
||||
|
||||
从本次更新开始,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 动态读取)
|
||||
```
|
||||
## 默认仓库状态
|
||||
|
||||
## 🎯 工作原理
|
||||
仓库内的静态版本现在故意保留为开发占位值:
|
||||
|
||||
### 1. 版本号提取
|
||||
当推送 Git Tag 时(如 `git tag v1.0.1`),Release 工作流的 `prepare` 任务自动提取版本号:
|
||||
- TAG: `v1.0.1` → VERSION: `1.0.1`
|
||||
- `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`
|
||||
|
||||
### 2. 自动更新 .csproj
|
||||
在三个平台的构建任务中,新增了 **"Update version in .csproj"** 步骤:
|
||||
这些值只是提醒“当前不是正式注入构建”,不能代表发布版本。
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
```powershell
|
||||
$VERSION = "1.0.1"
|
||||
(Get-Content file.csproj) -replace '<Version>.*?</Version>', "<Version>$VERSION</Version>" | Set-Content file.csproj
|
||||
```
|
||||
## Release 工作流怎么做
|
||||
|
||||
**Linux/macOS (Bash)**:
|
||||
```bash
|
||||
VERSION="1.0.1"
|
||||
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" file.csproj
|
||||
```
|
||||
Release 工作流会先从 tag 提取版本:
|
||||
|
||||
### 3. 构建和发布
|
||||
更新后的版本号被用于:
|
||||
- 程序集版本 (`AssemblyVersion`)
|
||||
- 包文件名 (`LanMountainDesktop-1.0.1-win-x64.zip`)
|
||||
- 应用内显示 (About 页面)
|
||||
- GitHub Release 标题
|
||||
- `v0.8.5.2` -> `0.8.5.2`
|
||||
- 程序集四段版本 -> `0.8.5.2`
|
||||
|
||||
## 📍 涉及的文件
|
||||
随后显式执行:
|
||||
|
||||
自动更新的文件:
|
||||
1. `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
- `scripts/Set-ReleaseVersion.ps1`
|
||||
|
||||
## ✅ 使用流程
|
||||
这个步骤会同步更新:
|
||||
|
||||
### 发布新版本
|
||||
- 主程序 `.csproj` 的 `Version`
|
||||
- Launcher `.csproj` 的 `Version`
|
||||
- Shared.Contracts `.csproj` 的 `Version`
|
||||
- `Directory.Build.props`
|
||||
- 主程序 `app.manifest`
|
||||
- Launcher `app.manifest`
|
||||
|
||||
```bash
|
||||
# 1. 更新代码(可选:代码中的版本号现在会自动更新)
|
||||
git add .
|
||||
git commit -m "feat: Add new features"
|
||||
之后构建和发布阶段继续通过 MSBuild 属性注入:
|
||||
|
||||
# 2. 创建版本标签
|
||||
git tag v1.0.1
|
||||
# 或带注释的标签
|
||||
git tag -a v1.0.1 -m "Release v1.0.1"
|
||||
- `Version`
|
||||
- `AssemblyVersion`
|
||||
- `FileVersion`
|
||||
- `InformationalVersion`
|
||||
|
||||
# 3. 推送标签到 GitHub
|
||||
git push origin v1.0.1
|
||||
因此最终会统一落到:
|
||||
|
||||
# 4. Release 工作流自动运行:
|
||||
# - 自动更新 .csproj 文件
|
||||
# - 构建所有平台
|
||||
# - 创建 GitHub Release
|
||||
# - 附带所有平台的发布包
|
||||
```
|
||||
- Launcher UI 读取到的应用版本
|
||||
- 应用设置页显示的版本
|
||||
- `version.json`
|
||||
- 程序集文件版本
|
||||
- Windows manifest
|
||||
- 安装包版本
|
||||
- GitHub 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 (自动版本同步)
|
||||
- 日常开发不要手动把仓库默认版本改成正式版本号。
|
||||
- 正式发版只需要打 tag,版本同步交给工作流。
|
||||
- 如果新增新的版本承载点,必须同时补到 `Set-ReleaseVersion.ps1` 和 Release 工作流里。
|
||||
|
||||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@@ -119,6 +119,13 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -364,6 +371,13 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -545,6 +559,13 @@ jobs:
|
||||
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 }}
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -514,3 +514,4 @@ nul
|
||||
/*.AppImage
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
/test-aot-publish
|
||||
|
||||
376
.kilo/package-lock.json
generated
Normal file
376
.kilo/package-lock.json
generated
Normal file
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.20.tgz",
|
||||
"integrity": "sha512-M5lMc58Mu9j1zveH+E3ZUKRHefzh+acNAqHGSG3TuF6K2l16KrZlCl38CZlgj2R5Qgaig6Jec/F2p9Rbn3BhCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.20",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.99",
|
||||
"@opentui/solid": ">=0.1.99"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.20.tgz",
|
||||
"integrity": "sha512-KUpu1fyzcAyZWpiv//834zGLN+PYzIH65crs15VTtUJ9CDvGqcj08EM0XlkF9jMuGQAjHjfRbvCfml3+YO31+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
|
||||
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
171
.kilo/plans/1776989126427-witty-island.md
Normal file
171
.kilo/plans/1776989126427-witty-island.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# LanMountainDesktop 启动器无法启动应用 - 问题分析与修复计划
|
||||
|
||||
## 1. 项目架构概述
|
||||
|
||||
LanMountainDesktop 采用**双进程架构**:
|
||||
- **Launcher** (`LanMountainDesktop.Launcher`) - 启动器,负责版本管理、更新、启动主程序
|
||||
- **Host** (`LanMountainDesktop`) - 主应用宿主
|
||||
|
||||
### 启动流程
|
||||
1. 用户启动 `LanMountainDesktop.Launcher.exe`
|
||||
2. Launcher 扫描 `app-*` 目录,选择最佳版本
|
||||
3. 检查并应用待处理的更新
|
||||
4. 处理插件升级队列
|
||||
5. 启动主程序 `app-{version}/LanMountainDesktop.exe`
|
||||
6. 通过 IPC 监控主程序启动进度
|
||||
|
||||
## 2. 问题分析
|
||||
|
||||
### 2.1 核心问题:主机可执行文件找不到
|
||||
|
||||
根据代码分析(`DeploymentLocator.cs`),启动器通过以下顺序查找主机可执行文件:
|
||||
|
||||
1. **显式 app-root**(如果通过命令行指定)
|
||||
2. **已发布部署**(查找 `app-*` 目录)
|
||||
3. **可移植主机**(直接在应用根目录)
|
||||
4. **调试主机**(开发模式,查找构建输出路径)
|
||||
5. **旧版回退路径**
|
||||
|
||||
**当前状态检查**:
|
||||
- ❌ 未找到 `app-*` 目录(生产部署结构不存在)
|
||||
- ❌ 未找到 `bin/Debug/**/*.exe`(项目未构建或构建输出不存在)
|
||||
|
||||
### 2.2 可能的启动失败原因
|
||||
|
||||
| 问题 | 描述 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **项目未构建** | LanMountainDesktop 主程序未编译,没有可执行文件 | P0 |
|
||||
| **部署结构缺失** | 生产模式下缺少 `app-*` 目录结构 | P0 |
|
||||
| **开发模式路径问题** | 调试模式下路径计算错误或构建输出不在预期位置 | P1 |
|
||||
| **.NET 版本问题** | 项目使用 .NET 10.0,运行环境可能缺少对应运行时 | P1 |
|
||||
| **更新应用失败** | `ApplyPendingUpdateAsync` 失败导致无法完成部署 | P2 |
|
||||
| **IPC 连接超时** | 主程序启动后未及时建立 IPC 连接,导致启动器超时 | P2 |
|
||||
|
||||
### 2.3 关键代码位置
|
||||
|
||||
- **主机查找逻辑**: `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs`
|
||||
- `FindCurrentDeploymentDirectory()` - 查找 app-* 目录
|
||||
- `ResolveHostExecutable()` - 解析主机路径
|
||||
|
||||
- **启动协调逻辑**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||
- `RunAsync()` - 主启动流程
|
||||
- `LaunchHostWithIpcAsync()` - 启动主机进程
|
||||
|
||||
- **更新引擎**: `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
|
||||
- `ApplyPendingUpdateAsync()` - 应用待处理的更新
|
||||
|
||||
## 3. 诊断步骤
|
||||
|
||||
### 步骤 1:检查构建状态
|
||||
```bash
|
||||
dotnet --info
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
### 步骤 2:验证主机可执行文件是否存在
|
||||
检查以下路径是否存在 `LanMountainDesktop.exe`:
|
||||
- `LanMountainDesktop/bin/Debug/net10.0/`
|
||||
- `LanMountainDesktop/bin/Release/net10.0/`
|
||||
|
||||
### 步骤 3:测试直接运行主程序(跳过 Launcher)
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
### 步骤 4:检查 Launcher 启动日志
|
||||
在开发模式下运行 Launcher 并查看控制台输出:
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
## 4. 修复计划
|
||||
|
||||
### 方案 A:构建并配置开发环境(推荐)
|
||||
|
||||
**适用场景**:开发或调试环境
|
||||
|
||||
1. **构建整个解决方案**
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
2. **验证构建输出**
|
||||
- 确认 `LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe` 存在
|
||||
- 确认 `LanMountainDesktop.Launcher/bin/Debug/net10.0/LanMountainDesktop.Launcher.exe` 存在
|
||||
|
||||
3. **测试 Launcher 启动**
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
4. **如果路径查找失败,检查 `DeploymentLocator.cs` 中的开发路径**
|
||||
- 当前逻辑(第 366-375 行)查找:
|
||||
- `../LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe`
|
||||
- `../LanMountainDesktop/bin/Release/net10.0/LanMountainDesktop.exe`
|
||||
- 确认这些路径与实际的构建输出路径匹配
|
||||
|
||||
### 方案 B:创建生产部署结构
|
||||
|
||||
**适用场景**:生产环境或模拟生产环境
|
||||
|
||||
1. **发布主程序**
|
||||
```bash
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Release -o app-1.0.0
|
||||
```
|
||||
|
||||
2. **创建 .current 标记文件**
|
||||
```bash
|
||||
echo. > app-1.0.0/.current
|
||||
```
|
||||
|
||||
3. **从 Launcher 启动**
|
||||
- Launcher 应该能找到 `app-1.0.0/LanMountainDesktop.exe`
|
||||
|
||||
### 方案 C:修复潜在的代码问题
|
||||
|
||||
如果上述方案无法解决问题,可能需要修复代码:
|
||||
|
||||
#### C1. 增强错误处理和日志
|
||||
在 `DeploymentLocator.cs` 中添加更详细的日志输出,帮助诊断路径查找失败的原因。
|
||||
|
||||
#### C2. 检查更新逻辑
|
||||
如果 `ApplyPendingUpdateAsync` 失败,可能导致启动中止。检查 `.launcher/update/incoming/` 目录是否有残留的更新文件。
|
||||
|
||||
#### C3. 调整超时设置
|
||||
如果主程序启动较慢,可以适当增加 `LauncherFlowCoordinator.cs` 中的超时时间:
|
||||
- `StartupSoftTimeout` (当前 10 秒)
|
||||
- `StartupHardTimeout` (当前 30 秒)
|
||||
|
||||
## 5. 建议执行顺序
|
||||
|
||||
1. ✅ **首先执行方案 A 的步骤 1-2**(构建项目)
|
||||
2. ✅ **执行诊断步骤 3**(测试直接运行主程序)
|
||||
3. ✅ **执行诊断步骤 4**(查看 Launcher 启动日志)
|
||||
4. 根据日志输出决定后续操作:
|
||||
- 如果显示 "host executable was not found" → 检查路径配置
|
||||
- 如果显示 "update apply failed" → 清理更新缓存
|
||||
- 如果主程序启动后超时 → 检查 IPC 连接或增加超时
|
||||
|
||||
## 6. 验证方法
|
||||
|
||||
修复后,通过以下方式验证:
|
||||
|
||||
```bash
|
||||
# 开发模式启动
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
|
||||
# 或直接运行 Launcher 可执行文件
|
||||
# (需要先构建 Launcher)
|
||||
```
|
||||
|
||||
启动后应该看到:
|
||||
1. Splash 窗口显示
|
||||
2. 主程序桌面窗口出现
|
||||
3. Launcher 自动退出(或最小化到托盘)
|
||||
|
||||
## 7. 注意事项
|
||||
|
||||
- 项目使用 .NET 10.0(`global.json` 指定版本 10.0.103)
|
||||
- 确保开发环境已安装对应的 .NET SDK
|
||||
- 如果修改了 `DeploymentLocator.cs` 的路径查找逻辑,需要同步更新文档 `docs/DEVELOPMENT.md`
|
||||
6
.trae/specs/independent-settings-window/checklist.md
Normal file
6
.trae/specs/independent-settings-window/checklist.md
Normal file
@@ -0,0 +1,6 @@
|
||||
- [x] 从桌面、托盘、IPC、组件库进入设置时,都会落到同一个设置窗口
|
||||
- [x] 设置已打开时再次触发设置入口,只会聚焦已有窗口,不会切换成关闭
|
||||
- [x] 设置窗口始终拥有独立任务栏图标,不受“桌面主窗口在任务栏显示图标”开关影响
|
||||
- [x] 点击“回到 Windows”后,只隐藏或最小化桌面主窗口,设置窗口保持可见
|
||||
- [x] 启用滑入滑出动画后,只有主窗口参与动画,设置窗口不参与
|
||||
- [x] 点击设置窗口关闭按钮后会真实关闭;再次打开时创建新的居中窗口
|
||||
78
.trae/specs/independent-settings-window/spec.md
Normal file
78
.trae/specs/independent-settings-window/spec.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 独立设置窗口 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** 新窗口按参考屏幕居中显示
|
||||
25
.trae/specs/independent-settings-window/tasks.md
Normal file
25
.trae/specs/independent-settings-window/tasks.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 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 相关的测试
|
||||
10
.trae/specs/launcher-shell-hardening/checklist.md
Normal file
10
.trae/specs/launcher-shell-hardening/checklist.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 验收清单
|
||||
|
||||
- [ ] 设置页重启后,Launcher 能重新接管并恢复到正确展示形态。
|
||||
- [ ] 插件升级辅助程序完成后,回拉的是 Launcher 而不是宿主 exe。
|
||||
- [ ] 已在托盘中的实例再次启动时,不会出现第二个主进程。
|
||||
- [ ] 托盘初始化失败时,应用不会进入无入口的 `TrayOnly`。
|
||||
- [ ] 托盘运行中丢失时,watchdog 能重建或自动恢复前台。
|
||||
- [ ] Launcher UI 版本与应用设置页版本一致。
|
||||
- [ ] 发布 tag `vX.Y.Z.W` 时,manifest、程序集、`version.json`、安装包和资产命名一致。
|
||||
- [ ] 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口、通知动画正常。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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.
|
||||
67
.trae/specs/launcher-shell-hardening/spec.md
Normal file
67
.trae/specs/launcher-shell-hardening/spec.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 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、主窗口入场、通知位置与动画正常。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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".
|
||||
14
.trae/specs/launcher-shell-hardening/tasks.md
Normal file
14
.trae/specs/launcher-shell-hardening/tasks.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 任务拆解
|
||||
|
||||
- [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] 补充规格与版本同步说明文档。
|
||||
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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.
|
||||
@@ -113,6 +113,15 @@
|
||||
- **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>1.0.0</Version>
|
||||
<Version>0.0.0-dev</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();
|
||||
_createAndAssignMainWindow(desktop);
|
||||
_startActivationListener();
|
||||
_createAndAssignMainWindow(desktop);
|
||||
}
|
||||
|
||||
_startWeatherRefresh();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
@@ -5,7 +6,11 @@ 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;
|
||||
|
||||
@@ -13,6 +18,12 @@ public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Initialize();
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
@@ -27,6 +38,12 @@ public partial class App : Application
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
@@ -44,6 +61,18 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
// 调试模式:只显示 DevDebugWindow,不走正常启动流程
|
||||
// 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI
|
||||
if (context.IsDebugMode && !context.IsPreviewCommand &&
|
||||
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Logger.Info("Debug mode active — showing DevDebugWindow instead of normal launch flow.");
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var updateWindow = new UpdateWindow();
|
||||
@@ -52,7 +81,7 @@ public partial class App : Application
|
||||
}
|
||||
else
|
||||
{
|
||||
var splashWindow = new SplashWindow();
|
||||
var splashWindow = CreateSplashWindow();
|
||||
splashWindow.Show();
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
@@ -68,7 +97,7 @@ public partial class App : Application
|
||||
case "preview-splash":
|
||||
{
|
||||
Logger.Info("Preview command: splash.");
|
||||
var splashWindow = new SplashWindow();
|
||||
var splashWindow = CreateSplashWindow();
|
||||
splashWindow.SetDebugMode(true);
|
||||
splashWindow.Show();
|
||||
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
||||
@@ -112,6 +141,27 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||
{
|
||||
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
||||
@@ -172,53 +222,330 @@ public partial class App : Application
|
||||
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);
|
||||
|
||||
try
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
coordinatorPipeName,
|
||||
out var reservedAttempt,
|
||||
out var activeCoordinatorAttempt))
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
result = await AttachToExistingCoordinatorAsync(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
new PluginInstallerService());
|
||||
currentSplashWindow,
|
||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
||||
|
||||
result = await coordinator.RunAsync(splashWindow).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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
HandleCoordinatorRequestAsync,
|
||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
||||
coordinatorServer.Start();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
try
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_requested",
|
||||
Message = "Launcher activated the existing desktop instance.",
|
||||
Details = result.Details
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
currentSplashWindow = CreateSplashWindow();
|
||||
currentSplashWindow.Show();
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success &&
|
||||
result.Code is not "host_not_found" &&
|
||||
(string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static async Task<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");
|
||||
@@ -238,15 +565,31 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ShowFailureWindowAsync(LauncherResult result)
|
||||
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();
|
||||
@@ -259,16 +602,76 @@ public partial class App : Application
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return;
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
|
||||
@@ -20,13 +21,22 @@ namespace LanMountainDesktop.Launcher;
|
||||
[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(DataLocationConfig))]
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -37,11 +37,25 @@ internal sealed class CommandContext
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||
/// 当满足以下任一条件时启用:
|
||||
/// 1. 明确指定 --debug 参数
|
||||
/// 2. 调试器附加(Debugger.IsAttached)
|
||||
/// 3. DOTNET_ENVIRONMENT 环境变量为 Development(IDE 调试启动时自动设置)
|
||||
/// </summary>
|
||||
public bool IsDebugMode =>
|
||||
Options.ContainsKey("debug") ||
|
||||
System.Diagnostics.Debugger.IsAttached;
|
||||
System.Diagnostics.Debugger.IsAttached ||
|
||||
IsDevelopmentEnvironment;
|
||||
|
||||
/// <summary>
|
||||
/// 是否为 Development 环境(DOTNET_ENVIRONMENT=Development)
|
||||
/// Rider/VS 调试启动时会自动设置此环境变量
|
||||
/// </summary>
|
||||
public bool IsDevelopmentEnvironment =>
|
||||
string.Equals(
|
||||
System.Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"),
|
||||
"Development",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPreviewCommand =>
|
||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -121,6 +135,7 @@ internal sealed class CommandContext
|
||||
return raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"normal" => "normal",
|
||||
"restart" => "restart",
|
||||
"postinstall" => "postinstall",
|
||||
"apply-update" => "apply-update",
|
||||
"plugin-install" => "plugin-install",
|
||||
@@ -146,6 +161,13 @@ 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];
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- 应用程序图标 -->
|
||||
@@ -25,6 +25,7 @@
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.250403001" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
</ItemGroup>
|
||||
@@ -35,6 +36,7 @@
|
||||
<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">
|
||||
|
||||
23
LanMountainDesktop.Launcher/Models/DataLocationModels.cs
Normal file
23
LanMountainDesktop.Launcher/Models/DataLocationModels.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal enum DataLocationMode
|
||||
{
|
||||
System,
|
||||
Portable
|
||||
}
|
||||
|
||||
internal sealed class DataLocationConfig
|
||||
{
|
||||
public string DataLocationMode { get; set; } = "System";
|
||||
|
||||
public string? SystemDataPath { get; set; }
|
||||
|
||||
public string? PortableDataPath { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class DataLocationPromptResult
|
||||
{
|
||||
public DataLocationMode SelectedMode { get; init; }
|
||||
|
||||
public bool MigrateExistingData { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
}
|
||||
65
LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs
Normal file
65
LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
@@ -4,10 +4,10 @@ using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class Program
|
||||
public static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static async Task<int> Main(string[] args)
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
@@ -66,7 +66,7 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"Launcher (Debug Mode)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "launch --debug",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Launch Mode)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "launch",
|
||||
@@ -9,6 +17,46 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Debug Window)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-debug",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Splash)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-splash",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Error)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-error",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Update)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-update",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview OOBE)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-oobe",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Update Check)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "update check",
|
||||
|
||||
@@ -166,7 +166,10 @@ internal static class Commands
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
|
||||
var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir)
|
||||
? launcherDir
|
||||
: AppContext.BaseDirectory);
|
||||
|
||||
// 发布版结构:Launcher 和 app-* 目录在同一目录
|
||||
// 检查当前目录是否有 app-* 子目录(发布版)
|
||||
|
||||
67
LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs
Normal file
67
LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DataLocationOobeStep : IOobeStep
|
||||
{
|
||||
private readonly DataLocationResolver _resolver;
|
||||
|
||||
public DataLocationOobeStep(DataLocationResolver resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var existingConfig = _resolver.LoadConfig();
|
||||
if (existingConfig is not null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step skipped: config already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
DataLocationPromptWindow? window = null;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
window = new DataLocationPromptWindow(_resolver);
|
||||
window.Show();
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
Logger.Warn("DataLocation OOBE step failed: window could not be created.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await window.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location.");
|
||||
_resolver.ApplyLocationChoice(DataLocationMode.System, null, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData);
|
||||
Logger.Info(
|
||||
$"DataLocation OOBE step: user selected '{result.SelectedMode}'. " +
|
||||
$"Migrate={result.MigrateExistingData}; Success={success}.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (window.IsVisible)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
270
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
270
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DataLocationResolver
|
||||
{
|
||||
private const string ConfigFileName = "data-location.config.json";
|
||||
private const string LauncherFolderName = "Launcher";
|
||||
private const string DesktopFolderName = "Desktop";
|
||||
|
||||
private readonly string _appRoot;
|
||||
private readonly string _defaultSystemDataPath;
|
||||
|
||||
public DataLocationResolver(string appRoot)
|
||||
{
|
||||
_appRoot = Path.GetFullPath(appRoot);
|
||||
_defaultSystemDataPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
}
|
||||
|
||||
public string AppRoot => _appRoot;
|
||||
|
||||
/// <summary>
|
||||
/// 默认系统数据路径(用户目录)
|
||||
/// </summary>
|
||||
public string DefaultSystemDataPath => _defaultSystemDataPath;
|
||||
|
||||
/// <summary>
|
||||
/// 默认便携模式数据路径(应用目录下的 AppData)
|
||||
/// </summary>
|
||||
public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData");
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否允许便携模式(应用目录是否可写)
|
||||
/// </summary>
|
||||
public bool IsPortableModeAllowed()
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(_appRoot, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(testFile, string.Empty);
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析数据根目录(用户选择的位置)
|
||||
/// </summary>
|
||||
public string ResolveDataRoot()
|
||||
{
|
||||
var config = LoadConfig();
|
||||
if (config is null)
|
||||
{
|
||||
return _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
|
||||
? config.PortableDataPath
|
||||
: _defaultSystemDataPath;
|
||||
return Path.GetFullPath(portablePath);
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(config.SystemDataPath)
|
||||
? Path.GetFullPath(config.SystemDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动器数据目录(日志、配置、状态等)
|
||||
/// </summary>
|
||||
public string ResolveLauncherDataPath()
|
||||
{
|
||||
return Path.Combine(ResolveDataRoot(), LauncherFolderName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 桌面应用数据目录(组件、设置、插件等)
|
||||
/// </summary>
|
||||
public string ResolveDesktopDataPath()
|
||||
{
|
||||
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据位置配置文件路径(保存在 Launcher 目录下)
|
||||
/// </summary>
|
||||
public string ResolveConfigPath()
|
||||
{
|
||||
return Path.Combine(ResolveLauncherDataPath(), ConfigFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动器日志目录
|
||||
/// </summary>
|
||||
public string ResolveLauncherLogsPath()
|
||||
{
|
||||
return Path.Combine(ResolveLauncherDataPath(), "logs");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动器状态目录
|
||||
/// </summary>
|
||||
public string ResolveLauncherStatePath()
|
||||
{
|
||||
return Path.Combine(ResolveLauncherDataPath(), "state");
|
||||
}
|
||||
|
||||
public DataLocationMode ResolveMode()
|
||||
{
|
||||
var config = LoadConfig();
|
||||
if (config is null)
|
||||
{
|
||||
return DataLocationMode.System;
|
||||
}
|
||||
|
||||
return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)
|
||||
? DataLocationMode.Portable
|
||||
: DataLocationMode.System;
|
||||
}
|
||||
|
||||
public DataLocationConfig? LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configPath = ResolveConfigPath();
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(configPath);
|
||||
return JsonSerializer.Deserialize(json, AppJsonContext.Default.DataLocationConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to load data location config. Error='{ex.Message}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SaveConfig(DataLocationConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = ResolveLauncherDataPath();
|
||||
Directory.CreateDirectory(launcherPath);
|
||||
|
||||
var configPath = ResolveConfigPath();
|
||||
var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig);
|
||||
File.WriteAllText(configPath, json);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to save data location config. Error='{ex.Message}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
||||
? Path.GetFullPath(customPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
var config = new DataLocationConfig
|
||||
{
|
||||
DataLocationMode = mode.ToString(),
|
||||
SystemDataPath = _defaultSystemDataPath,
|
||||
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
|
||||
};
|
||||
|
||||
// 先创建目录结构
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ResolveLauncherDataPath());
|
||||
Directory.CreateDirectory(ResolveDesktopDataPath());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to create data directories. Error='{ex.Message}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
if (!SaveConfig(config))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (migrateExistingData && mode == DataLocationMode.Portable)
|
||||
{
|
||||
MigrateSystemDataToPortable(targetDataRoot);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HasExistingSystemData()
|
||||
{
|
||||
var desktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
|
||||
if (!Directory.Exists(desktopPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var markerFiles = new[]
|
||||
{
|
||||
Path.Combine(desktopPath, "settings.json"),
|
||||
Path.Combine(desktopPath, "component-state.db"),
|
||||
Path.Combine(desktopPath, "app.db")
|
||||
};
|
||||
|
||||
return markerFiles.Any(File.Exists);
|
||||
}
|
||||
|
||||
private void MigrateSystemDataToPortable(string targetDataRoot)
|
||||
{
|
||||
if (!HasExistingSystemData())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceDesktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
|
||||
var targetDesktopPath = Path.Combine(targetDataRoot, DesktopFolderName);
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(targetDesktopPath);
|
||||
|
||||
// 迁移桌面数据
|
||||
if (Directory.Exists(sourceDesktopPath))
|
||||
{
|
||||
CopyDirectory(sourceDesktopPath, targetDesktopPath);
|
||||
}
|
||||
|
||||
Logger.Info($"Data migration completed. Target='{targetDataRoot}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Data migration failed. Target='{targetDataRoot}'. Error='{ex.Message}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destDir)
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
foreach (var file in Directory.GetFiles(sourceDir))
|
||||
{
|
||||
var destFile = Path.Combine(destDir, Path.GetFileName(file));
|
||||
File.Copy(file, destFile, overwrite: true);
|
||||
}
|
||||
|
||||
foreach (var subDir in Directory.GetDirectories(sourceDir))
|
||||
{
|
||||
var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir));
|
||||
CopyDirectory(subDir, destSubDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,8 @@ internal sealed class DeploymentLocator
|
||||
Version = ParseVersionFromDirectory(path),
|
||||
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
|
||||
})
|
||||
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current æ ‡è®°çš„æŽ’å‰<EFBFBD>é<EFBFBD>¢
|
||||
.ThenByDescending(x => x.Version) // ç„¶å<EFBFBD>ŽæŒ‰ç‰ˆæœ¬å<EFBFBD>·é™<EFBFBD>åº<EFBFBD>
|
||||
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 鏍囪鐨勬帓鍓嶉潰
|
||||
.ThenByDescending(x => x.Version) // 鐒跺悗鎸夌増鏈彿闄嶅簭
|
||||
.ToList();
|
||||
|
||||
if (validInstallations.Count == 0)
|
||||
@@ -204,12 +204,16 @@ internal sealed class DeploymentLocator
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
||||
{
|
||||
var fullSavedPath = Path.GetFullPath(savedCustomPath);
|
||||
searchedPaths.Add(fullSavedPath);
|
||||
if (File.Exists(fullSavedPath))
|
||||
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
|
||||
{
|
||||
source = "debug_saved_custom_path";
|
||||
return 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}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +233,21 @@ internal sealed class DeploymentLocator
|
||||
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,
|
||||
@@ -275,7 +294,7 @@ internal sealed class DeploymentLocator
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
|
||||
// 1. 首先查找 app-{version} 目录(生产环境)
|
||||
// 1. 棣栧厛鏌ユ壘 app-{version} 鐩綍锛堢敓浜х幆澧冿級
|
||||
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
@@ -299,13 +318,21 @@ internal sealed class DeploymentLocator
|
||||
return inParent;
|
||||
}
|
||||
|
||||
// 4. å¼€å<EFBFBD>‘模å¼<EFBFBD>:如果å<EFBFBD>¯ç”¨äº†å¼€å<EFBFBD>‘模å¼<EFBFBD>,优先使用ä¿<EFBFBD>å˜çš„自定义路径
|
||||
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
||||
{
|
||||
return 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}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var devPath = ScanDevelopmentPaths(executable);
|
||||
@@ -315,7 +342,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
// 5. å¼€å<EFBFBD>‘模å¼<EFBFBD>:查找主程åº<EFBFBD>项目的输出目录
|
||||
// 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
|
||||
var devPaths = GetDevelopmentPaths(executable);
|
||||
foreach (var devPath in devPaths)
|
||||
{
|
||||
@@ -329,55 +356,63 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫æ<EFBFBD><EFBFBD>å¼€å<EFBFBD>‘路径(开å<EFBFBD>‘模å¼<EFBFBD>)
|
||||
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
|
||||
/// </summary>
|
||||
private static string? ScanDevelopmentPaths(string executable)
|
||||
{
|
||||
var solutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
|
||||
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// ä»?Launcher 项目è¿<C3A8>行
|
||||
// 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目
|
||||
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(solutionRoot, "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),
|
||||
|
||||
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<C3A8>行
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// dev-test 目录
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
Logger.Info($"Scanning development path: {path}");
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Logger.Info($"Found host at: {path}");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获å<EFBFBD>–å¼€å<EFBFBD>‘环境å<EFBFBD>¯èƒ½çš„主程åº<EFBFBD>è·¯å¾? /// </summary>
|
||||
/// 鑾峰彇寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忚経
|
||||
/// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 计算解决方案根目录:从 LanMountainDesktop.Launcher\bin\Debug\net10.0\ 向上4级
|
||||
var solutionRoot = Path.GetFullPath(Path.Combine(launcherDir, "..", "..", "..", ".."));
|
||||
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// ä»?Launcher 项目è¿<C3A8>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目
|
||||
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 向后兼容:如果 Launcher 在特殊目录结构中
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<EFBFBD>行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»?dev-test 目录è¿<C3A8>行
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
// dev-test 目录
|
||||
Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
|
||||
return possiblePaths.Select(Path.GetFullPath).Distinct();
|
||||
}
|
||||
|
||||
@@ -409,8 +444,8 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最近的N个版æœ? /// </summary>
|
||||
/// <param name="minVersionsToKeep">最少ä¿<EFBFBD>留版本数,默è®?ä¸?/param>
|
||||
/// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? /// </summary>
|
||||
/// <param name="minVersionsToKeep">鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
|
||||
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
||||
{
|
||||
try
|
||||
@@ -438,10 +473,10 @@ internal sealed class DeploymentLocator
|
||||
|
||||
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
|
||||
|
||||
// 确定è¦<EFBFBD>ä¿<EFBFBD>留的版本
|
||||
// 纭畾瑕佷繚鐣欑殑鐗堟湰
|
||||
var versionsToKeep = new HashSet<string>();
|
||||
|
||||
// 1. 总是ä¿<EFBFBD>留当å‰<EFBFBD>版本
|
||||
// 1. 鎬绘槸淇濈暀褰撳墠鐗堟湰
|
||||
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
|
||||
if (currentVersion != null)
|
||||
{
|
||||
@@ -449,7 +484,7 @@ internal sealed class DeploymentLocator
|
||||
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
|
||||
}
|
||||
|
||||
// 2. ä¿<EFBFBD>留最近的N个有效版本(ä¸<EFBFBD>åŒ…æ‹¬å·²æ ‡è®°destroy的)
|
||||
// 2. 淇濈暀鏈€杩戠殑N涓湁鏁堢増鏈紙涓嶅寘鎷凡鏍囪destroy鐨勶級
|
||||
var activeVersions = validDeployments
|
||||
.Where(d => !d.IsDestroyed)
|
||||
.Take(minVersionsToKeep)
|
||||
@@ -461,8 +496,9 @@ internal sealed class DeploymentLocator
|
||||
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
|
||||
}
|
||||
|
||||
// 3. ä¿<EFBFBD>留有快照的版本(用于回滚)
|
||||
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
|
||||
var resolver = new DataLocationResolver(_appRoot);
|
||||
var snapshotDir = Path.Combine(resolver.ResolveLauncherDataPath(), "snapshots");
|
||||
if (Directory.Exists(snapshotDir))
|
||||
{
|
||||
try
|
||||
@@ -485,17 +521,17 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照解æž<EFBFBD>错误
|
||||
// 蹇界暐蹇収瑙f瀽閿欒
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照目录访问错误
|
||||
// 蹇界暐蹇収鐩綍璁块棶閿欒
|
||||
}
|
||||
}
|
||||
|
||||
// 清ç<EFBFBD>†ä¸<EFBFBD>需è¦<EFBFBD>的版本
|
||||
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
|
||||
foreach (var deployment in validDeployments)
|
||||
{
|
||||
if (versionsToKeep.Contains(deployment.Path))
|
||||
@@ -509,7 +545,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略å<EFBFBD>–æ¶ˆæ ‡è®°å¤±è´¥
|
||||
// 蹇界暐鍙栨秷鏍囪澶辫触
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -524,11 +560,11 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// å¿½ç•¥æ ‡è®°å¤±è´¥
|
||||
// 蹇界暐鏍囪澶辫触
|
||||
}
|
||||
}
|
||||
|
||||
// å°<EFBFBD>è¯•åˆ é™¤
|
||||
// 灏濊瘯鍒犻櫎
|
||||
try
|
||||
{
|
||||
Directory.Delete(deployment.Path, recursive: true);
|
||||
@@ -536,7 +572,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// å¿½ç•¥åˆ é™¤å¤±è´¥(å<>¯èƒ½æ–‡ä»¶è¢«å<C2AB> ç”?,下次å<C2A1>¯åЍå†<C3A5>试
|
||||
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
|
||||
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
|
||||
}
|
||||
}
|
||||
@@ -544,12 +580,12 @@ internal sealed class DeploymentLocator
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
|
||||
// 忽略清ç<EFBFBD>†å¤±è´¥
|
||||
// 蹇界暐娓呯悊澶辫触
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroy的部署(兼容旧方法)
|
||||
/// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
|
||||
/// </summary>
|
||||
[Obsolete("Use CleanupOldDeployments instead")]
|
||||
public void CleanupDestroyedDeployments()
|
||||
@@ -581,36 +617,17 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从部署目录读å<EFBFBD>–版本信æ<EFBFBD>? /// </summary>
|
||||
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </summary>
|
||||
public AppVersionInfo GetVersionInfo()
|
||||
{
|
||||
var deploymentDir = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var versionFile = Path.Combine(deploymentDir, "version.json");
|
||||
if (File.Exists(versionFile))
|
||||
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var resolved = AppVersionProvider.ResolveFromPackageRoot(_appRoot, executableName);
|
||||
return string.IsNullOrWhiteSpace(resolved.Version)
|
||||
? new AppVersionInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFile);
|
||||
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
|
||||
if (info is not null)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate"
|
||||
}
|
||||
}
|
||||
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
: resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +560,11 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
213
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
213
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
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
|
||||
{
|
||||
public const string DataRootOptionName = "data-root";
|
||||
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root", DataRootOptionName,
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
public static HostLaunchPlan Build(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
HostResolutionResult resolution,
|
||||
string? dataRoot = null)
|
||||
{
|
||||
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, dataRoot);
|
||||
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
|
||||
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
|
||||
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
|
||||
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
environment["LMD_DATA_ROOT"] = dataRoot;
|
||||
}
|
||||
|
||||
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,
|
||||
string? dataRoot = null)
|
||||
{
|
||||
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}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
arguments.Add($"--{DataRootOptionName}={dataRoot}");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 启动器背景图片服务
|
||||
/// </summary>
|
||||
internal static class LauncherBackgroundService
|
||||
{
|
||||
private const string PictureFileName = "Launcher Picture";
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private const double WindowAspectRatio = 7.0 / 5.0; // 700:500
|
||||
private const double AspectRatioTolerance = 0.15; // 15% 误差
|
||||
|
||||
private static Bitmap? _cachedBitmap;
|
||||
private static string? _cachedPath;
|
||||
|
||||
/// <summary>
|
||||
/// 背景图片信息
|
||||
/// </summary>
|
||||
public record BackgroundImageInfo
|
||||
{
|
||||
public required bool Exists { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public Bitmap? Bitmap { get; init; }
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public double AspectRatio { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载背景图片
|
||||
/// </summary>
|
||||
public static BackgroundImageInfo LoadBackgroundImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolver = new DataLocationResolver(AppContext.BaseDirectory);
|
||||
var launcherPath = resolver.ResolveLauncherDataPath();
|
||||
|
||||
// 查找图片文件
|
||||
var imagePath = FindImageFile(launcherPath);
|
||||
if (imagePath == null)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = "未找到背景图片文件"
|
||||
};
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
var fileInfo = new FileInfo(imagePath);
|
||||
if (fileInfo.Length > MaxFileSize)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)"
|
||||
};
|
||||
}
|
||||
|
||||
// 使用缓存
|
||||
if (_cachedBitmap != null && _cachedPath == imagePath)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = true,
|
||||
FilePath = imagePath,
|
||||
Bitmap = _cachedBitmap,
|
||||
Width = _cachedBitmap.PixelSize.Width,
|
||||
Height = _cachedBitmap.PixelSize.Height,
|
||||
AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height
|
||||
};
|
||||
}
|
||||
|
||||
// 加载图片
|
||||
var bitmap = new Bitmap(imagePath);
|
||||
var width = bitmap.PixelSize.Width;
|
||||
var height = bitmap.PixelSize.Height;
|
||||
var aspectRatio = (double)width / height;
|
||||
|
||||
// 校验比例
|
||||
var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio;
|
||||
if (ratioDiff > AspectRatioTolerance)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AspectRatio = aspectRatio,
|
||||
ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})"
|
||||
};
|
||||
}
|
||||
|
||||
// 缓存图片
|
||||
_cachedBitmap = bitmap;
|
||||
_cachedPath = imagePath;
|
||||
|
||||
Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})");
|
||||
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = true,
|
||||
FilePath = imagePath,
|
||||
Bitmap = bitmap,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AspectRatio = aspectRatio
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}");
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = $"加载失败: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找图片文件
|
||||
/// </summary>
|
||||
private static string? FindImageFile(string directory)
|
||||
{
|
||||
var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
|
||||
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
var path = Path.Combine(directory, PictureFileName + ext);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 也尝试不带扩展名的匹配(如果文件本身就有扩展名)
|
||||
var files = Directory.GetFiles(directory, PictureFileName + ".*");
|
||||
foreach (var file in files)
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (extensions.Contains(ext))
|
||||
{
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
_cachedBitmap?.Dispose();
|
||||
_cachedBitmap = null;
|
||||
_cachedPath = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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 appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return resolver.ResolveLauncherDataPath();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,12 +53,22 @@ internal static class Logger
|
||||
/// </summary>
|
||||
private static string? GetLogDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return resolver.ResolveLauncherLogsPath();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
|
||||
return Path.Combine(appData, "LanMountainDesktop", "Launcher", "logs");
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -68,7 +78,7 @@ internal static class Logger
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
return Path.Combine(launcherDir, ".launcher", "logs");
|
||||
return Path.Combine(launcherDir, "Launcher", "logs");
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -21,9 +21,9 @@ internal sealed class OobeStateService
|
||||
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||
|
||||
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? GetDefaultStateRoot()
|
||||
? ResolveStateRoot(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
|
||||
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||
}
|
||||
@@ -208,14 +208,22 @@ internal sealed class OobeStateService
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDefaultStateRoot()
|
||||
private static string ResolveStateRoot(string appRoot)
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
try
|
||||
{
|
||||
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return resolver.ResolveDataRoot();
|
||||
}
|
||||
catch
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||
}
|
||||
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,28 @@ internal sealed class PluginInstallerService
|
||||
return null;
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
string? allowedRoot = null;
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
|
||||
if (string.IsNullOrWhiteSpace(allowedRoot))
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
|
||||
}
|
||||
|
||||
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
|
||||
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
550
LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs
Normal file
550
LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs
Normal file
@@ -0,0 +1,550 @@
|
||||
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 readonly string _statePath;
|
||||
private readonly string _mutexName;
|
||||
private string? _ownedAttemptId;
|
||||
|
||||
public StartupAttemptRegistry()
|
||||
: this(ResolveDefaultStatePath())
|
||||
{
|
||||
}
|
||||
|
||||
private static string ResolveDefaultStatePath()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return Path.Combine(resolver.ResolveLauncherStatePath(), "startup-attempt.json");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 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(json, AppJsonContext.Default.StartupAttemptRecord);
|
||||
}
|
||||
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, AppJsonContext.Default.StartupAttemptRecord));
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
68
LanMountainDesktop.Launcher/Services/ThemeService.cs
Normal file
68
LanMountainDesktop.Launcher/Services/ThemeService.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
using FluentAvalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 主题服务,管理启动器的主题设置
|
||||
/// </summary>
|
||||
public static class ThemeService
|
||||
{
|
||||
private static ThemeVariant _currentTheme = ThemeVariant.Light;
|
||||
private static string _accentColor = "#0078D4";
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前主题
|
||||
/// </summary>
|
||||
public static ThemeVariant CurrentTheme => _currentTheme;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前主题色
|
||||
/// </summary>
|
||||
public static string AccentColor => _accentColor;
|
||||
|
||||
/// <summary>
|
||||
/// 应用主题设置
|
||||
/// </summary>
|
||||
public static void ApplyTheme(ThemeMode mode, string accentColor)
|
||||
{
|
||||
_currentTheme = mode switch
|
||||
{
|
||||
ThemeMode.Dark => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Light
|
||||
};
|
||||
_accentColor = accentColor;
|
||||
|
||||
// 应用到当前应用程序
|
||||
if (Application.Current is { } app)
|
||||
{
|
||||
app.RequestedThemeVariant = _currentTheme;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用浅色主题
|
||||
/// </summary>
|
||||
public static void ApplyLightTheme(string accentColor)
|
||||
{
|
||||
ApplyTheme(ThemeMode.Light, accentColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用深色主题
|
||||
/// </summary>
|
||||
public static void ApplyDarkTheme(string accentColor)
|
||||
{
|
||||
ApplyTheme(ThemeMode.Dark, accentColor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主题模式
|
||||
/// </summary>
|
||||
public enum ThemeMode
|
||||
{
|
||||
Light,
|
||||
Dark
|
||||
}
|
||||
@@ -7,7 +7,6 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class UpdateEngineService
|
||||
{
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string SnapshotsDirectoryName = "snapshots";
|
||||
@@ -30,7 +29,8 @@ internal sealed class UpdateEngineService
|
||||
{
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_appRoot = deploymentLocator.GetAppRoot();
|
||||
_launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName);
|
||||
var resolver = new DataLocationResolver(_appRoot);
|
||||
_launcherRoot = resolver.ResolveLauncherDataPath();
|
||||
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
||||
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
|
||||
}
|
||||
@@ -1458,7 +1458,7 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupIncomingArtifacts()
|
||||
internal void CleanupIncomingArtifacts()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
private bool _isErrorEnabled = true;
|
||||
private bool _isUpdateEnabled = true;
|
||||
private bool _isOobeEnabled = true;
|
||||
private bool _isDataLocationEnabled = true;
|
||||
private string _statusMessage = "就绪";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
@@ -87,6 +88,23 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据位置选择页面是否启用实际功能
|
||||
/// </summary>
|
||||
public bool IsDataLocationEnabled
|
||||
{
|
||||
get => _isDataLocationEnabled;
|
||||
set
|
||||
{
|
||||
if (_isDataLocationEnabled != value)
|
||||
{
|
||||
_isDataLocationEnabled = value;
|
||||
OnPropertyChanged();
|
||||
UpdateStatus($"数据位置选择: {(value ? "功能模式" : "仅查看")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 状态信息
|
||||
@@ -131,6 +149,11 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
/// </summary>
|
||||
public ICommand OpenOobeCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开数据位置选择页面命令
|
||||
/// </summary>
|
||||
public ICommand OpenDataLocationCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 全部切换到查看模式命令
|
||||
/// </summary>
|
||||
@@ -170,6 +193,11 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
/// </summary>
|
||||
public event EventHandler<OobeOpenEventArgs>? OpenOobeRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求打开数据位置选择页面
|
||||
/// </summary>
|
||||
public event EventHandler<DataLocationOpenEventArgs>? OpenDataLocationRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求关闭窗口
|
||||
/// </summary>
|
||||
@@ -199,12 +227,18 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
OpenOobeRequested?.Invoke(this, new OobeOpenEventArgs(IsOobeEnabled));
|
||||
});
|
||||
|
||||
OpenDataLocationCommand = new RelayCommand(() =>
|
||||
{
|
||||
OpenDataLocationRequested?.Invoke(this, new DataLocationOpenEventArgs(IsDataLocationEnabled));
|
||||
});
|
||||
|
||||
SetAllViewOnlyCommand = new RelayCommand(() =>
|
||||
{
|
||||
IsSplashEnabled = false;
|
||||
IsErrorEnabled = false;
|
||||
IsUpdateEnabled = false;
|
||||
IsOobeEnabled = false;
|
||||
IsDataLocationEnabled = false;
|
||||
UpdateStatus("全部页面已切换到查看模式");
|
||||
});
|
||||
|
||||
@@ -214,6 +248,7 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
IsErrorEnabled = true;
|
||||
IsUpdateEnabled = true;
|
||||
IsOobeEnabled = true;
|
||||
IsDataLocationEnabled = true;
|
||||
UpdateStatus("全部页面已切换到功能模式");
|
||||
});
|
||||
|
||||
@@ -260,4 +295,10 @@ public class OobeOpenEventArgs : EventArgs
|
||||
public OobeOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
public class DataLocationOpenEventArgs : EventArgs
|
||||
{
|
||||
public bool IsFunctional { get; }
|
||||
public DataLocationOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
153
LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
Normal file
153
LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
Normal file
@@ -0,0 +1,153 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="480"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.DataLocationPromptWindow"
|
||||
x:DataType="views:DataLocationPromptWindow"
|
||||
Title="选择数据保存位置"
|
||||
Width="520"
|
||||
Height="480"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Grid x:Name="ContentGrid"
|
||||
Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform Y="24" />
|
||||
</Grid.RenderTransform>
|
||||
<Grid Margin="36" RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,20">
|
||||
<TextBlock Text="选择数据保存位置"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="决定将应用数据保存在哪里,可随时在设置中更改"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="12">
|
||||
<Border x:Name="AdminWarningBanner"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ui:SymbolIcon Symbol="Important"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
<TextBlock Text="无法保存到应用目录"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="SystemOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="16,14">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="SystemRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,12,0"
|
||||
GroupName="DataLocation"
|
||||
IsChecked="True" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="保存在系统用户目录(推荐)"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock x:Name="SystemPathText"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PortableOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="16,14">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="PortableRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,12,0"
|
||||
GroupName="DataLocation"
|
||||
IsEnabled="False" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="保存在应用安装目录"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock x:Name="PortablePathText"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="MigrationInfoBorder"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ui:SymbolIcon Symbol="Message"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
<TextBlock x:Name="MigrationInfoText"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="10"
|
||||
Margin="0,20,0,0">
|
||||
<Button x:Name="CancelButton"
|
||||
Content="取消"
|
||||
Theme="{DynamicResource ButtonTheme}"
|
||||
IsVisible="False" />
|
||||
<Button x:Name="ConfirmButton"
|
||||
Content="确认"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,310 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class DataLocationPromptWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<DataLocationPromptResult?> _completionSource = new();
|
||||
private readonly DataLocationResolver _resolver;
|
||||
private bool _isTransitioning;
|
||||
|
||||
public DataLocationPromptWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
_resolver = new DataLocationResolver(AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
internal DataLocationPromptWindow(DataLocationResolver resolver)
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
BindControls();
|
||||
UpdateUiState();
|
||||
}
|
||||
|
||||
private void BindControls()
|
||||
{
|
||||
var systemRadio = this.FindControl<RadioButton>("SystemRadio");
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var confirmButton = this.FindControl<Button>("ConfirmButton");
|
||||
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||
|
||||
if (systemRadio is not null)
|
||||
{
|
||||
systemRadio.Checked += OnSelectionChanged;
|
||||
systemRadio.Unchecked += OnSelectionChanged;
|
||||
}
|
||||
|
||||
if (portableRadio is not null)
|
||||
{
|
||||
portableRadio.Checked += OnSelectionChanged;
|
||||
portableRadio.Unchecked += OnSelectionChanged;
|
||||
}
|
||||
|
||||
if (confirmButton is not null)
|
||||
{
|
||||
confirmButton.Click += OnConfirmClick;
|
||||
}
|
||||
|
||||
if (cancelButton is not null)
|
||||
{
|
||||
cancelButton.Click += OnCancelClick;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUiState()
|
||||
{
|
||||
var systemPathText = this.FindControl<TextBlock>("SystemPathText");
|
||||
var portablePathText = this.FindControl<TextBlock>("PortablePathText");
|
||||
var adminWarningBanner = this.FindControl<Border>("AdminWarningBanner");
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var migrationInfoBorder = this.FindControl<Border>("MigrationInfoBorder");
|
||||
var migrationInfoText = this.FindControl<TextBlock>("MigrationInfoText");
|
||||
|
||||
if (systemPathText is not null)
|
||||
{
|
||||
systemPathText.Text = _resolver.DefaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (portablePathText is not null)
|
||||
{
|
||||
portablePathText.Text = _resolver.DefaultPortableDataPath;
|
||||
}
|
||||
|
||||
var portableAllowed = _resolver.IsPortableModeAllowed();
|
||||
|
||||
if (adminWarningBanner is not null)
|
||||
{
|
||||
adminWarningBanner.IsVisible = !portableAllowed;
|
||||
}
|
||||
|
||||
if (portableRadio is not null)
|
||||
{
|
||||
portableRadio.IsEnabled = portableAllowed;
|
||||
}
|
||||
|
||||
var hasExistingData = _resolver.HasExistingSystemData();
|
||||
if (migrationInfoBorder is not null)
|
||||
{
|
||||
migrationInfoBorder.IsVisible = hasExistingData;
|
||||
}
|
||||
|
||||
if (migrationInfoText is not null && hasExistingData)
|
||||
{
|
||||
migrationInfoText.Text = "检测到系统用户目录已有应用数据。如果选择保存在应用安装目录,将自动迁移现有数据。";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var systemRadio = this.FindControl<RadioButton>("SystemRadio");
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var systemBorder = this.FindControl<Border>("SystemOptionBorder");
|
||||
var portableBorder = this.FindControl<Border>("PortableOptionBorder");
|
||||
|
||||
var isSystem = systemRadio?.IsChecked == true;
|
||||
var isPortable = portableRadio?.IsChecked == true;
|
||||
|
||||
if (systemBorder is not null)
|
||||
{
|
||||
systemBorder.BorderBrush = isSystem
|
||||
? Application.Current?.FindResource("AccentFillColorDefaultBrush") as IBrush
|
||||
: Application.Current?.FindResource("CardStrokeColorDefaultBrush") as IBrush;
|
||||
systemBorder.BorderThickness = isSystem ? new Thickness(2) : new Thickness(1);
|
||||
}
|
||||
|
||||
if (portableBorder is not null)
|
||||
{
|
||||
portableBorder.BorderBrush = isPortable
|
||||
? Application.Current?.FindResource("AccentFillColorDefaultBrush") as IBrush
|
||||
: Application.Current?.FindResource("CardStrokeColorDefaultBrush") as IBrush;
|
||||
portableBorder.BorderThickness = isPortable ? new Thickness(2) : new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnConfirmClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTransitioning = true;
|
||||
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var selectedMode = portableRadio?.IsChecked == true
|
||||
? DataLocationMode.Portable
|
||||
: DataLocationMode.System;
|
||||
|
||||
var migrateExistingData = selectedMode == DataLocationMode.Portable && _resolver.HasExistingSystemData();
|
||||
|
||||
try
|
||||
{
|
||||
await PlayExitAnimationAsync();
|
||||
_completionSource.TrySetResult(new DataLocationPromptResult
|
||||
{
|
||||
SelectedMode = selectedMode,
|
||||
MigrateExistingData = migrateExistingData
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error during data location prompt exit animation: {ex.Message}");
|
||||
_completionSource.TrySetResult(new DataLocationPromptResult
|
||||
{
|
||||
SelectedMode = selectedMode,
|
||||
MigrateExistingData = migrateExistingData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCancelClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTransitioning = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PlayExitAnimationAsync();
|
||||
_completionSource.TrySetResult(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error during data location prompt cancel: {ex.Message}");
|
||||
_completionSource.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
await PlayEntranceAnimationAsync();
|
||||
}
|
||||
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
|
||||
contentGrid.RenderTransform = translateTransform;
|
||||
|
||||
contentGrid.Opacity = 0;
|
||||
translateTransform.Y = 24;
|
||||
|
||||
var fadeInAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(500),
|
||||
Easing = new CubicEaseOut(),
|
||||
FillMode = FillMode.Forward,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(500)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(500),
|
||||
Easing = new CubicEaseOut(),
|
||||
FillMode = FillMode.Forward,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 24.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(500)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Task.WhenAll(
|
||||
fadeInAnimation.RunAsync(contentGrid),
|
||||
slideUpAnimation.RunAsync(translateTransform));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error playing data location prompt entrance animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
await Task.Delay(150);
|
||||
return;
|
||||
}
|
||||
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
Easing = new CubicEaseIn(),
|
||||
FillMode = FillMode.Forward,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(200)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fadeOutAnimation.RunAsync(contentGrid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error playing data location prompt exit animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal Task<DataLocationPromptResult?> WaitForChoiceAsync() => _completionSource.Task;
|
||||
}
|
||||
@@ -141,6 +141,32 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 数据位置选择页面 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="15">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="📁 数据位置选择 (DataLocationPromptWindow)"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="选择数据保存位置"
|
||||
FontSize="11"
|
||||
Opacity="0.6"
|
||||
Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<ToggleSwitch Content="启用功能"
|
||||
IsChecked="{Binding IsDataLocationEnabled}"
|
||||
OnContent="功能"
|
||||
OffContent="查看" />
|
||||
<Button Content="打开"
|
||||
Command="{Binding OpenDataLocationCommand}"
|
||||
HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public partial class DevDebugWindow : Window
|
||||
_viewModel.OpenErrorRequested += OnOpenErrorRequested;
|
||||
_viewModel.OpenUpdateRequested += OnOpenUpdateRequested;
|
||||
_viewModel.OpenOobeRequested += OnOpenOobeRequested;
|
||||
_viewModel.OpenDataLocationRequested += OnOpenDataLocationRequested;
|
||||
_viewModel.CloseRequested += OnCloseRequested;
|
||||
}
|
||||
|
||||
@@ -135,6 +136,17 @@ public partial class DevDebugWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开数据位置选择页面
|
||||
/// </summary>
|
||||
private void OnOpenDataLocationRequested(object? sender, DataLocationOpenEventArgs e)
|
||||
{
|
||||
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
var window = new DataLocationPromptWindow(resolver);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭窗口
|
||||
/// </summary>
|
||||
|
||||
@@ -5,52 +5,41 @@ using Avalonia.Platform.Storage;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误调试窗口 - 开发人员专用调试设置
|
||||
/// </summary>
|
||||
public partial class ErrorDebugWindow : Window
|
||||
{
|
||||
private string? _selectedHostPath;
|
||||
private bool _isInitialized = false;
|
||||
private bool _isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 选择的主程序路径
|
||||
/// </summary>
|
||||
public bool WasAccepted { get; private set; }
|
||||
|
||||
public string? SelectedHostPath => _selectedHostPath;
|
||||
|
||||
public ErrorDebugWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再初始化组件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
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();
|
||||
|
||||
// 设置初始值(在视觉树准备好后)
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
|
||||
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||
{
|
||||
devModeToggle.IsChecked = IsDevModeEnabled;
|
||||
}
|
||||
@@ -60,113 +49,72 @@ public partial class ErrorDebugWindow : Window
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
// 开发模式开关
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||
{
|
||||
devModeToggle.IsCheckedChanged += (s, e) =>
|
||||
devModeToggle.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
|
||||
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
|
||||
};
|
||||
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
|
||||
}
|
||||
|
||||
// 浏览按钮
|
||||
var browseButton = this.FindControl<Button>("BrowseButton");
|
||||
if (browseButton is not null)
|
||||
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
|
||||
{
|
||||
browseButton.Click += OnBrowseClick;
|
||||
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
|
||||
}
|
||||
|
||||
// 确定按钮
|
||||
var okButton = this.FindControl<Button>("OkButton");
|
||||
if (okButton is not null)
|
||||
if (this.FindControl<Button>("OkButton") is { } okButton)
|
||||
{
|
||||
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) =>
|
||||
okButton.Click += (_, _) =>
|
||||
{
|
||||
// 取消时恢复原始状态
|
||||
IsDevModeEnabled = false;
|
||||
_selectedHostPath = null;
|
||||
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
|
||||
WasAccepted = true;
|
||||
Close();
|
||||
};
|
||||
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
|
||||
}
|
||||
else
|
||||
|
||||
if (this.FindControl<Button>("CancelButton") is { } cancelButton)
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
|
||||
cancelButton.Click += (_, _) => Close();
|
||||
}
|
||||
|
||||
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 = "选择阑山桌面主程序",
|
||||
Title = "Select LanMountainDesktop host executable",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType("可执行文件")
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("Executable")
|
||||
{
|
||||
Patterns = OperatingSystem.IsWindows()
|
||||
? new[] { "*.exe" }
|
||||
: new[] { "*" }
|
||||
? ["*.exe"]
|
||||
: ["*"]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||
if (result.Count > 0)
|
||||
if (result.Count <= 0)
|
||||
{
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新路径显示
|
||||
/// </summary>
|
||||
private void UpdatePathDisplay(string? path)
|
||||
{
|
||||
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
||||
if (pathTextBlock is not null)
|
||||
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
|
||||
{
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,102 +3,96 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="280"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
|
||||
x:DataType="views:ErrorWindow"
|
||||
Title="阑山桌面"
|
||||
Width="520"
|
||||
Height="280"
|
||||
Title="LanMountain Desktop"
|
||||
Width="560"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
Background="#111318"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:ErrorWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<!-- Fluent Design 风格对话框布局 -->
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 主内容区域:左侧图标 + 右侧文字 -->
|
||||
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- 左侧:错误图标(可点击进入调试模式) -->
|
||||
<Grid Grid.Row="0"
|
||||
Margin="24"
|
||||
ColumnDefinitions="Auto,*">
|
||||
<Border x:Name="ErrorIconBorder"
|
||||
Grid.Column="0"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0,4,16,0"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="24"
|
||||
Width="52"
|
||||
Height="52"
|
||||
Margin="0,4,18,0"
|
||||
Background="#2B161A"
|
||||
CornerRadius="26"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock Text=""
|
||||
<TextBlock Text="!"
|
||||
FontSize="24"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
FontWeight="Bold"
|
||||
Foreground="#FFB4AB"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<!-- 右侧:标题 + 内容 -->
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<!-- 标题 -->
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="10">
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="启动失败"
|
||||
FontSize="18"
|
||||
Text="Launcher could not confirm startup"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
Foreground="#F6F7FB"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock x:Name="ErrorMessageText"
|
||||
Text="找不到阑山桌面应用程序。"
|
||||
Text="LanMountain Desktop did not reach the expected startup state."
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Foreground="#D2D7E1"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="20"/>
|
||||
|
||||
<!-- 建议信息 -->
|
||||
LineHeight="22" />
|
||||
|
||||
<TextBlock x:Name="SuggestionText"
|
||||
Text="请确保应用程序已正确安装,或尝试重新安装。"
|
||||
Text="You can inspect logs, retry when the old process is gone, or reactivate the current instance."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Foreground="#9BA5B7"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="18"
|
||||
Margin="0,4,0,0"/>
|
||||
LineHeight="20" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:按钮区域 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="24,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
Padding="24,16"
|
||||
Background="#171A21">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="OpenLogButton"
|
||||
Grid.Column="0"
|
||||
Content="打开日志"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Left"/>
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button x:Name="ExitButton"
|
||||
Content="退出"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="RetryButton"
|
||||
Content="重试"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
Content="Open Logs"
|
||||
MinWidth="108"
|
||||
Height="34"
|
||||
HorizontalAlignment="Left" />
|
||||
|
||||
<Button x:Name="SecondaryActionButton"
|
||||
Grid.Column="1"
|
||||
Content="Wait"
|
||||
MinWidth="108"
|
||||
Height="34"
|
||||
IsVisible="False" />
|
||||
|
||||
<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>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -1,542 +1,314 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
|
||||
/// </summary>
|
||||
public partial class ErrorWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
|
||||
private int _iconClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugMode = false;
|
||||
private string? _customHostPath;
|
||||
|
||||
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _iconClickCount;
|
||||
private bool _isDebugMode;
|
||||
private bool _devModeEnabled;
|
||||
private string? _customHostPath;
|
||||
private ErrorWindowResult _primaryAction = ErrorWindowResult.Retry;
|
||||
private ErrorWindowResult? _secondaryAction;
|
||||
|
||||
public ErrorWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 先加载保存的状态
|
||||
_devModeEnabled = LoadDevModeStateInternal();
|
||||
_customHostPath = LoadCustomHostPathInternal();
|
||||
|
||||
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
Loaded += OnWindowLoaded;
|
||||
Closed += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件 - 视觉树已准备好
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
|
||||
InitializeComponents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件
|
||||
/// </summary>
|
||||
private void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Window opened and visible");
|
||||
}
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Initializing components...");
|
||||
|
||||
// 错误图标点击事件(进入调试模式 - 隐藏功能)
|
||||
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
|
||||
if (errorIconBorder is not null)
|
||||
{
|
||||
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||
Console.WriteLine("[ErrorWindow] ErrorIconBorder event bound successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find ErrorIconBorder!");
|
||||
}
|
||||
|
||||
// 按钮事件
|
||||
var retryButton = this.FindControl<Button>("RetryButton");
|
||||
var exitButton = this.FindControl<Button>("ExitButton");
|
||||
var openLogButton = this.FindControl<Button>("OpenLogButton");
|
||||
|
||||
if (retryButton is not null)
|
||||
{
|
||||
retryButton.Click += OnRetryClick;
|
||||
Console.WriteLine("[ErrorWindow] RetryButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find RetryButton!");
|
||||
}
|
||||
|
||||
if (exitButton is not null)
|
||||
{
|
||||
exitButton.Click += OnExitClick;
|
||||
Console.WriteLine("[ErrorWindow] ExitButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!");
|
||||
}
|
||||
|
||||
if (openLogButton is not null)
|
||||
{
|
||||
openLogButton.Click += OnOpenLogClick;
|
||||
Console.WriteLine("[ErrorWindow] OpenLogButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find OpenLogButton!");
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorWindow] Components initialization completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置错误消息
|
||||
/// </summary>
|
||||
public void SetErrorMessage(string message)
|
||||
{
|
||||
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
|
||||
if (errorText is not null)
|
||||
if (this.FindControl<TextBlock>("ErrorMessageText") is { } errorText)
|
||||
{
|
||||
errorText.Text = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
_isDebugMode = isDebugMode;
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
if (titleText is not null && isDebugMode)
|
||||
if (isDebugMode && this.FindControl<TextBlock>("TitleText") is { } titleText)
|
||||
{
|
||||
titleText.Text = "[调试模式] 错误页面";
|
||||
titleText.Text = "[Debug] Launcher error";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户选择的主程序路径
|
||||
/// </summary>
|
||||
public string? GetCustomHostPath() => _customHostPath;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户选择
|
||||
/// </summary>
|
||||
public Task<ErrorWindowResult> WaitForChoiceAsync()
|
||||
public void ConfigureForHostNotFound()
|
||||
{
|
||||
return _completionSource.Task;
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
|
||||
/// </summary>
|
||||
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
|
||||
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);
|
||||
}
|
||||
|
||||
public string? GetCustomHostPath() => _customHostPath;
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_iconClickCount++;
|
||||
|
||||
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
|
||||
{
|
||||
EnterDebugMode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 进入调试模式 - 显示调试窗口
|
||||
/// </summary>
|
||||
private async void EnterDebugMode()
|
||||
{
|
||||
_isDebugMode = true;
|
||||
|
||||
// 创建并显示调试窗口
|
||||
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
// 订阅调试窗口关闭事件
|
||||
debugWindow.Closed += (s, e) =>
|
||||
debugWindow.Closed += (_, _) =>
|
||||
{
|
||||
// 更新状态
|
||||
if (!debugWindow.WasAccepted)
|
||||
{
|
||||
_isDebugMode = false;
|
||||
_iconClickCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||
_customHostPath = debugWindow.SelectedHostPath;
|
||||
|
||||
// 保存开发模式状态和自定义路径
|
||||
SaveDevModeStateInternal(_devModeEnabled);
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
|
||||
// 如果启用了开发模式且没有选择路径,自动扫描
|
||||
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
|
||||
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
|
||||
{
|
||||
ScanDevPaths();
|
||||
// 扫描到路径后也保存
|
||||
if (!string.IsNullOrEmpty(_customHostPath))
|
||||
{
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
}
|
||||
}
|
||||
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _customHostPath));
|
||||
|
||||
_isDebugMode = false;
|
||||
_iconClickCount = 0;
|
||||
};
|
||||
|
||||
await debugWindow.ShowDialog(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描开发路径
|
||||
/// </summary>
|
||||
private void ScanDevPaths()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
_customHostPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置存储的基础目录
|
||||
/// </summary>
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户状态)
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
var configDir = Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// LocalApplicationData 不可用,回退到 Launcher 所在目录
|
||||
}
|
||||
|
||||
// 回退方案:使用 Launcher 所在目录
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var configDir = Path.Combine(launcherDir, ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 最后的兜底:使用当前目录
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保配置目录存在
|
||||
/// </summary>
|
||||
private static bool EnsureConfigDirectory(string dirPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dirPath))
|
||||
{
|
||||
Directory.CreateDirectory(dirPath);
|
||||
Console.WriteLine($"[ErrorWindow] Created config directory: {dirPath}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to create config directory: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveDevModeStateInternal(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save dev mode: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save dev mode state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static bool LoadDevModeStateInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
|
||||
if (File.Exists(devModeFile))
|
||||
{
|
||||
var content = File.ReadAllText(devModeFile).Trim();
|
||||
var enabled = content == "1";
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state loaded: {enabled}");
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load dev mode state: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveCustomHostPathInternal(string? path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save custom path: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
File.WriteAllText(hostPathFile, path ?? string.Empty);
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save custom host path: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static string? LoadCustomHostPathInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
|
||||
if (File.Exists(hostPathFile))
|
||||
{
|
||||
var content = File.ReadAllText(hostPathFile).Trim();
|
||||
// 验证路径是否仍然有效
|
||||
if (!string.IsNullOrEmpty(content) && File.Exists(content))
|
||||
{
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
|
||||
return content;
|
||||
}
|
||||
|
||||
// 路径已失效,清理配置文件
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
|
||||
try
|
||||
{
|
||||
File.Delete(hostPathFile);
|
||||
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
|
||||
}
|
||||
catch (Exception clearEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否启用了开发模式(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
public static bool CheckDevModeEnabled()
|
||||
{
|
||||
return LoadDevModeStateInternal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
public static string? GetSavedCustomHostPath()
|
||||
{
|
||||
return LoadCustomHostPathInternal();
|
||||
}
|
||||
|
||||
private void OnRetryClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Retry);
|
||||
}
|
||||
|
||||
private void OnExitClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开日志文件
|
||||
/// </summary>
|
||||
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFilePath = Logger.GetLogFilePath();
|
||||
|
||||
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
|
||||
if (!string.IsNullOrWhiteSpace(logFilePath) && File.Exists(logFilePath))
|
||||
{
|
||||
// 如果没有日志文件,打开日志目录
|
||||
var logDir = Path.GetDirectoryName(logFilePath);
|
||||
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
|
||||
{
|
||||
OpenFolder(logDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 尝试打开配置目录
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (Directory.Exists(configDir))
|
||||
{
|
||||
OpenFolder(configDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] No log file or directory available");
|
||||
}
|
||||
}
|
||||
OpenPath(logFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
|
||||
OpenFile(logFilePath);
|
||||
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)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
|
||||
Debug.WriteLine($"[ErrorWindow] Failed to open log path: {ex}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ScanDevPaths()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var candidatePaths = 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)
|
||||
};
|
||||
|
||||
foreach (var candidate in candidatePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
_customHostPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件
|
||||
/// </summary>
|
||||
private static void OpenFile(string filePath)
|
||||
private static void OpenPath(string path)
|
||||
{
|
||||
try
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{filePath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
|
||||
Process.Start("open", path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件夹
|
||||
/// </summary>
|
||||
private static void OpenFolder(string folderPath)
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{folderPath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", folderPath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
|
||||
}
|
||||
return LauncherDebugSettingsStore.ConfigBaseDirectory;
|
||||
}
|
||||
|
||||
private static bool LoadDevModeStateInternal()
|
||||
{
|
||||
return LauncherDebugSettingsStore.IsDevModeEnabled();
|
||||
}
|
||||
|
||||
private static string? LoadCustomHostPathInternal()
|
||||
{
|
||||
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口用户选择结果
|
||||
/// </summary>
|
||||
public enum ErrorWindowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 重试
|
||||
/// </summary>
|
||||
Retry,
|
||||
|
||||
/// <summary>
|
||||
/// 退出
|
||||
/// </summary>
|
||||
Exit
|
||||
Exit,
|
||||
ActivateExisting,
|
||||
ContinueWaiting
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600"
|
||||
d:DesignWidth="700"
|
||||
d:DesignHeight="500"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||
x:DataType="views:OobeWindow"
|
||||
Title="欢迎使用阑山桌面"
|
||||
Width="600"
|
||||
Width="700"
|
||||
Height="500"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
@@ -22,54 +23,598 @@
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid x:Name="ContentGrid">
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Margin="48" RowDefinitions="*,Auto">
|
||||
<!-- 中央内容区域 -->
|
||||
<StackPanel Grid.Row="0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="24">
|
||||
|
||||
<!-- 顶部:完成状态勾号图标 -->
|
||||
<Border Width="80"
|
||||
Height="80"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="40"
|
||||
HorizontalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Accept"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 中央:欢迎文字 -->
|
||||
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
<!-- 步骤 1: 打字机动画开场 -->
|
||||
<Grid x:Name="TypingStep" Margin="60,80,60,60">
|
||||
<!-- 主标题区域(左上角) -->
|
||||
<StackPanel HorizontalAlignment="Left" VerticalAlignment="Top" Spacing="16">
|
||||
<!-- 打字机文本区域 -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="TypingTextBlock"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
FontFamily="Consolas, Monaco, 'Courier New', monospace" />
|
||||
<Border x:Name="CursorBorder"
|
||||
Width="3"
|
||||
Height="28"
|
||||
Background="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="4,0,0,4">
|
||||
<Border.Styles>
|
||||
<Style Selector="Border">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.8" IterationCount="INFINITE">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="50%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="51%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 副标题区域(流光渐变动画 + 打字机效果) -->
|
||||
<StackPanel x:Name="SubtitlePanel" Opacity="0" IsVisible="False" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="NextGenTextBlock"
|
||||
FontSize="48"
|
||||
FontWeight="Bold"
|
||||
FontFamily="Consolas, Monaco, 'Courier New', monospace">
|
||||
<TextBlock.Foreground>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0.0" Color="#0078D4" />
|
||||
<GradientStop Offset="0.33" Color="#7B68EE" />
|
||||
<GradientStop Offset="0.66" Color="#FF8C00" />
|
||||
<GradientStop Offset="1.0" Color="#107C10" />
|
||||
</LinearGradientBrush>
|
||||
</TextBlock.Foreground>
|
||||
</TextBlock> <Border x:Name="SubtitleCursorBorder"
|
||||
Width="4"
|
||||
Height="48"
|
||||
Background="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="4,0,0,4"
|
||||
IsVisible="False">
|
||||
<Border.Styles>
|
||||
<Style Selector="Border">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.8" IterationCount="INFINITE">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="50%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="51%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="DashboardTextBlock"
|
||||
FontSize="48"
|
||||
FontWeight="Bold"
|
||||
FontFamily="Consolas, Monaco, 'Courier New', monospace"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 按钮动画区域(左下角) -->
|
||||
<Grid x:Name="ButtonAnimationArea"
|
||||
Width="280"
|
||||
Height="80"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,40"
|
||||
IsVisible="False">
|
||||
|
||||
<!-- 方框边框(由鼠标画出) -->
|
||||
<Border x:Name="DrawnBorder"
|
||||
Width="160"
|
||||
Height="56"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Background="Transparent"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
BorderThickness="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
|
||||
</Border>
|
||||
|
||||
<!-- 开始按钮(从方框中弹出) -->
|
||||
<Button x:Name="StartButton"
|
||||
Width="160"
|
||||
Height="56"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Theme="{DynamicResource AccentButtonTheme}"
|
||||
Opacity="0"
|
||||
IsVisible="False"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Button.RenderTransform>
|
||||
<ScaleTransform ScaleX="0.1" ScaleY="0.1" />
|
||||
</Button.RenderTransform>
|
||||
<TextBlock Text="开始使用"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold" />
|
||||
</Button>
|
||||
|
||||
<!-- 鼠标光标 -->
|
||||
<Canvas x:Name="MouseCursor"
|
||||
Width="24"
|
||||
Height="24"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="-50,-50,0,0"
|
||||
IsVisible="False">
|
||||
<Path Data="M0,0 L0,18 L4,14 L7,20 L10,19 L7,13 L12,13 Z"
|
||||
Fill="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
Stroke="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
StrokeThickness="1" />
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 2: 主题选择页面 -->
|
||||
<Grid x:Name="ThemeStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,24">
|
||||
<TextBlock Text="个性化你的桌面"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="选择你喜欢的主题样式,可随时在设置中更改"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="20">
|
||||
<!-- 浅色/深色模式选择 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="外观模式"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Border x:Name="LightModeOption"
|
||||
Grid.Column="0"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="16"
|
||||
Cursor="Hand">
|
||||
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||
<Border Width="48"
|
||||
Height="48"
|
||||
Background="#F3F3F3"
|
||||
CornerRadius="8"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<fi:SymbolIcon Symbol="WeatherSunny"
|
||||
FontSize="24"
|
||||
Foreground="#5F5F5F"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="浅色模式"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<RadioButton x:Name="LightModeRadio"
|
||||
GroupName="ThemeMode"
|
||||
IsChecked="True"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="DarkModeOption"
|
||||
Grid.Column="1"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="16"
|
||||
Cursor="Hand">
|
||||
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||
<Border Width="48"
|
||||
Height="48"
|
||||
Background="#1E1E1E"
|
||||
CornerRadius="8"
|
||||
BorderBrush="#333333"
|
||||
BorderThickness="1">
|
||||
<fi:SymbolIcon Symbol="WeatherMoon"
|
||||
FontSize="24"
|
||||
Foreground="#E0E0E0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="深色模式"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<RadioButton x:Name="DarkModeRadio"
|
||||
GroupName="ThemeMode"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 主题色选择 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="主题色"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<WrapPanel x:Name="AccentColorPanel" HorizontalAlignment="Left">
|
||||
<!-- 预设颜色 -->
|
||||
<Border x:Name="BlueColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#0078D4"
|
||||
CornerRadius="20"
|
||||
BorderThickness="3"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand">
|
||||
<Border.Styles>
|
||||
<Style Selector="Border:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.1)" />
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
</Border>
|
||||
<Border x:Name="PurpleColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#7B68EE"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="GreenColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#107C10"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="OrangeColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#D83B01"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="PinkColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#E3008C"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="TealColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#008080"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 莫奈取色来源 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="莫奈取色来源"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="从壁纸自动提取主题色,让界面与桌面完美融合"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<Border x:Name="MonetFromWallpaperOption"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="12"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="MonetFromWallpaperRadio"
|
||||
Grid.Column="0"
|
||||
GroupName="MonetSource"
|
||||
IsChecked="True"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="从桌面壁纸取色"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="自动分析当前壁纸颜色生成主题"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="MonetFromCustomOption"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="12"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="MonetFromCustomRadio"
|
||||
Grid.Column="0"
|
||||
GroupName="MonetSource"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="自定义图片取色"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="选择一张图片作为取色来源"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="MonetDisabledOption"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="12"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="MonetDisabledRadio"
|
||||
Grid.Column="0"
|
||||
GroupName="MonetSource"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="不使用莫奈取色"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="使用固定的预设主题色"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12"
|
||||
Margin="0,24,0,0">
|
||||
<Button x:Name="ThemeBackButton"
|
||||
Content="返回"
|
||||
Theme="{DynamicResource ButtonTheme}" />
|
||||
<Button x:Name="ThemeNextButton"
|
||||
Content="下一步"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 3: 数据位置选择页面 -->
|
||||
<Grid x:Name="DataLocationStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,24">
|
||||
<TextBlock Text="选择数据保存位置"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="决定将应用数据保存在哪里,可随时在设置中更改"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="16">
|
||||
<Border x:Name="AdminWarningBanner"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Data="M12,2 L1,21 L23,21 Z M11,9 L13,9 L13,15 L11,15 Z M11,17 L13,17 L13,19 L11,19 Z"
|
||||
Width="16"
|
||||
Height="16"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
<TextBlock Text="无法保存到应用目录"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="SystemOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="16,14"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="SystemRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,12,0"
|
||||
GroupName="DataLocation"
|
||||
IsChecked="True" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="保存在系统用户目录(推荐)"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock x:Name="SystemPathText"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PortableOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="16,14"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="PortableRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,12,0"
|
||||
GroupName="DataLocation"
|
||||
IsEnabled="False" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="保存在应用安装目录"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock x:Name="PortablePathText"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="MigrationInfoBorder"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Data="M9,16.17 L4.83,12 L3.41,13.41 L9,19 L21,7 L19.59,5.59 Z"
|
||||
Width="16"
|
||||
Height="16"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
<TextBlock x:Name="MigrationInfoText"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12"
|
||||
Margin="0,24,0,0">
|
||||
<Button x:Name="DataLocationBackButton"
|
||||
Content="返回"
|
||||
Theme="{DynamicResource ButtonTheme}" />
|
||||
<Button x:Name="DataLocationNextButton"
|
||||
Content="下一步"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 4: 欢迎完成页面 -->
|
||||
<Grid x:Name="WelcomeStep" Margin="48" RowDefinitions="*,Auto" IsVisible="False">
|
||||
<StackPanel Grid.Row="0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="32">
|
||||
|
||||
<Border Width="96"
|
||||
Height="96"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="48"
|
||||
HorizontalAlignment="Center">
|
||||
<PathIcon Data="M9,16.17 L4.83,12 L3.41,13.41 L9,19 L21,7 L19.59,5.59 Z"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="12" HorizontalAlignment="Center">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
FontSize="32"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="你的桌面,不止一面"
|
||||
FontSize="14"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- 底部:圆形开始按钮 -->
|
||||
<Button Grid.Row="1"
|
||||
x:Name="EnterButton"
|
||||
HorizontalAlignment="Center"
|
||||
Width="56"
|
||||
Height="56"
|
||||
Margin="0,0,0,16"
|
||||
Margin="0,0,0,24"
|
||||
Theme="{DynamicResource AccentButtonTheme}"
|
||||
CornerRadius="28">
|
||||
<ui:SymbolIcon Symbol="Forward"
|
||||
<fi:SymbolIcon Symbol="ArrowRight"
|
||||
FontSize="24"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"/>
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -1,197 +1,709 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// OOBE(首次使用体验)窗口 - 欢迎页面
|
||||
/// </summary>
|
||||
public partial class OobeWindow : Window
|
||||
{
|
||||
private const int AnimationDurationMs = 300;
|
||||
private const int TypingDelayMs = 100;
|
||||
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
private bool _isTransitioning = false;
|
||||
private readonly DataLocationResolver _resolver;
|
||||
private bool _isTransitioning;
|
||||
private bool _isDebugMode;
|
||||
private int _currentStep = 1;
|
||||
|
||||
// 数据位置选择
|
||||
private DataLocationMode _selectedDataLocationMode = DataLocationMode.System;
|
||||
private bool _migrateExistingData;
|
||||
|
||||
// 主题选择
|
||||
private Services.ThemeMode _selectedThemeMode = Services.ThemeMode.Light;
|
||||
private string _selectedAccentColor = "#0078D4";
|
||||
private MonetSource _selectedMonetSource = MonetSource.Wallpaper;
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
|
||||
// 延迟到窗口加载完成后再初始化
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
|
||||
_resolver = new DataLocationResolver(appRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
|
||||
|
||||
var enterButton = this.FindControl<Button>("EnterButton");
|
||||
if (enterButton is not null)
|
||||
{
|
||||
enterButton.Click += OnEnterClick;
|
||||
Console.WriteLine("[OobeWindow] EnterButton event bound successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[OobeWindow] Failed to find EnterButton!");
|
||||
}
|
||||
_isDebugMode = isDebugMode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件 - 播放入场动画
|
||||
/// </summary>
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
|
||||
await PlayEntranceAnimationAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放入场动画
|
||||
/// </summary>
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取内容元素
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接返回
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡入动画
|
||||
var fadeInAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
Easing = new CubicEaseOut(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 创建向上滑动动画
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
Easing = new CubicEaseOut(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 应用动画
|
||||
await fadeInAnimation.RunAsync(contentGrid);
|
||||
await slideUpAnimation.RunAsync(contentGrid);
|
||||
|
||||
Console.WriteLine("[OobeWindow] Entrance animation completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户点击开始按钮
|
||||
/// </summary>
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
/// <summary>
|
||||
/// 进入按钮点击事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
InitializeDataLocationStep();
|
||||
SetupEventHandlers();
|
||||
}
|
||||
|
||||
private void SetupEventHandlers()
|
||||
{
|
||||
// 步骤 1: 开始按钮
|
||||
if (this.FindControl<Button>("StartButton") is { } startButton)
|
||||
{
|
||||
startButton.Click += OnStartButtonClick;
|
||||
}
|
||||
|
||||
// 步骤 2: 主题选择页面
|
||||
if (this.FindControl<Button>("ThemeBackButton") is { } themeBackButton)
|
||||
{
|
||||
themeBackButton.Click += OnThemeBackClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("ThemeNextButton") is { } themeNextButton)
|
||||
{
|
||||
themeNextButton.Click += OnThemeNextClick;
|
||||
}
|
||||
|
||||
// 浅色/深色模式选择
|
||||
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
|
||||
{
|
||||
lightModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Light);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
|
||||
{
|
||||
darkModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Dark);
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
|
||||
{
|
||||
lightModeRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (lightModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Light);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("DarkModeRadio") is { } darkModeRadio)
|
||||
{
|
||||
darkModeRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (darkModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Dark);
|
||||
};
|
||||
}
|
||||
|
||||
// 主题色选择
|
||||
SetupAccentColorHandlers();
|
||||
|
||||
// 莫奈取色来源选择
|
||||
if (this.FindControl<Border>("MonetFromWallpaperOption") is { } monetWallpaperOption)
|
||||
{
|
||||
monetWallpaperOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Wallpaper);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("MonetFromCustomOption") is { } monetCustomOption)
|
||||
{
|
||||
monetCustomOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Custom);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("MonetDisabledOption") is { } monetDisabledOption)
|
||||
{
|
||||
monetDisabledOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Disabled);
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromWallpaperRadio") is { } monetWallpaperRadio)
|
||||
{
|
||||
monetWallpaperRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (monetWallpaperRadio.IsChecked == true) SelectMonetSource(MonetSource.Wallpaper);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromCustomRadio") is { } monetCustomRadio)
|
||||
{
|
||||
monetCustomRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (monetCustomRadio.IsChecked == true) SelectMonetSource(MonetSource.Custom);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetDisabledRadio") is { } monetDisabledRadio)
|
||||
{
|
||||
monetDisabledRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (monetDisabledRadio.IsChecked == true) SelectMonetSource(MonetSource.Disabled);
|
||||
};
|
||||
}
|
||||
|
||||
// 步骤 3: 数据位置选择页面
|
||||
if (this.FindControl<Button>("DataLocationBackButton") is { } dataLocationBackButton)
|
||||
{
|
||||
dataLocationBackButton.Click += OnDataLocationBackClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("DataLocationNextButton") is { } dataLocationNextButton)
|
||||
{
|
||||
dataLocationNextButton.Click += OnDataLocationNextClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("SystemOptionBorder") is { } systemOption)
|
||||
{
|
||||
systemOption.PointerPressed += (s, e) => SelectDataLocationMode(DataLocationMode.System);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("PortableOptionBorder") is { } portableOption)
|
||||
{
|
||||
portableOption.PointerPressed += (s, e) => SelectDataLocationMode(DataLocationMode.Portable);
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("SystemRadio") is { } systemRadio)
|
||||
{
|
||||
systemRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (systemRadio.IsChecked == true) SelectDataLocationMode(DataLocationMode.System);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
|
||||
{
|
||||
portableRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (portableRadio.IsChecked == true) SelectDataLocationMode(DataLocationMode.Portable);
|
||||
};
|
||||
}
|
||||
|
||||
// 步骤 4: 欢迎完成页面
|
||||
if (this.FindControl<Button>("EnterButton") is { } enterButton)
|
||||
{
|
||||
enterButton.Click += OnEnterClick;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupAccentColorHandlers()
|
||||
{
|
||||
var colorMap = new Dictionary<string, string>
|
||||
{
|
||||
{ "BlueColor", "#0078D4" },
|
||||
{ "PurpleColor", "#7B68EE" },
|
||||
{ "GreenColor", "#107C10" },
|
||||
{ "OrangeColor", "#D83B01" },
|
||||
{ "PinkColor", "#E3008C" },
|
||||
{ "TealColor", "#008080" }
|
||||
};
|
||||
|
||||
foreach (var (name, color) in colorMap)
|
||||
{
|
||||
if (this.FindControl<Border>(name) is { } colorBorder)
|
||||
{
|
||||
colorBorder.PointerPressed += (s, e) => SelectAccentColor(name, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
await PlayTypingAnimationAsync();
|
||||
}
|
||||
|
||||
private async Task PlayTypingAnimationAsync()
|
||||
{
|
||||
var typingTextBlock = this.FindControl<TextBlock>("TypingTextBlock");
|
||||
var cursorBorder = this.FindControl<Border>("CursorBorder");
|
||||
var subtitlePanel = this.FindControl<StackPanel>("SubtitlePanel");
|
||||
var buttonAnimationArea = this.FindControl<Grid>("ButtonAnimationArea");
|
||||
var startButton = this.FindControl<Button>("StartButton");
|
||||
var mouseCursor = this.FindControl<Canvas>("MouseCursor");
|
||||
|
||||
if (typingTextBlock == null || cursorBorder == null) return;
|
||||
|
||||
// 打字机效果:阑山桌面 LanMountain Desktop(在同一行)
|
||||
var fullText = "阑山桌面 LanMountain Desktop";
|
||||
for (int i = 0; i <= fullText.Length; i++)
|
||||
{
|
||||
typingTextBlock.Text = fullText.Substring(0, i);
|
||||
await Task.Delay(TypingDelayMs);
|
||||
}
|
||||
|
||||
// 停顿一下
|
||||
await Task.Delay(500);
|
||||
|
||||
// 隐藏光标
|
||||
cursorBorder.IsVisible = false;
|
||||
|
||||
// 显示副标题(打字机效果:下一代 互动信息看板)
|
||||
if (subtitlePanel != null)
|
||||
{
|
||||
subtitlePanel.IsVisible = true;
|
||||
subtitlePanel.Opacity = 1;
|
||||
await PlaySubtitleTypingAnimationAsync();
|
||||
}
|
||||
|
||||
// 停顿一下再显示按钮
|
||||
await Task.Delay(400);
|
||||
|
||||
// 显示按钮动画区域
|
||||
if (buttonAnimationArea != null)
|
||||
{
|
||||
buttonAnimationArea.IsVisible = true;
|
||||
}
|
||||
|
||||
// 鼠标拖拽按钮入场
|
||||
if (mouseCursor != null && startButton != null)
|
||||
{
|
||||
await AnimateMouseDragButtonAsync(mouseCursor, startButton);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AnimateMouseDragButtonAsync(Canvas mouseCursor, Button button)
|
||||
{
|
||||
// 初始处于画面外部的 X 坐标
|
||||
var startX = -400.0;
|
||||
var endX = 0.0;
|
||||
|
||||
button.IsVisible = true;
|
||||
button.Opacity = 1;
|
||||
button.RenderTransform = new TranslateTransform(startX, 0);
|
||||
|
||||
// 鼠标位于按钮上,比如偏移 (100, 30) 的位置
|
||||
var mouseOffsetX = 100.0;
|
||||
var mouseOffsetY = 30.0;
|
||||
mouseCursor.Margin = new Thickness(startX + mouseOffsetX, mouseOffsetY, 0, 0);
|
||||
mouseCursor.IsVisible = true;
|
||||
|
||||
await Task.Delay(300);
|
||||
|
||||
var duration = 800;
|
||||
var steps = 40;
|
||||
var delay = duration / steps;
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
var progress = (double)i / steps;
|
||||
var eased = EaseOutBack(progress); // 使用 EaseOutBack 营造“拖拽到位”的清脆回弹感
|
||||
|
||||
var currentX = startX + (endX - startX) * eased;
|
||||
|
||||
button.RenderTransform = new TranslateTransform(currentX, 0);
|
||||
mouseCursor.Margin = new Thickness(currentX + mouseOffsetX, mouseOffsetY, 0, 0);
|
||||
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
// 隐藏鼠标光标
|
||||
await AnimateOpacityAsync(mouseCursor, 1, 0, 200);
|
||||
mouseCursor.IsVisible = false;
|
||||
}
|
||||
|
||||
private async Task PlaySubtitleTypingAnimationAsync()
|
||||
{
|
||||
var nextGenTextBlock = this.FindControl<TextBlock>("NextGenTextBlock");
|
||||
var dashboardTextBlock = this.FindControl<TextBlock>("DashboardTextBlock");
|
||||
var subtitleCursorBorder = this.FindControl<Border>("SubtitleCursorBorder");
|
||||
|
||||
if (nextGenTextBlock == null || dashboardTextBlock == null) return;
|
||||
|
||||
// 获取渐变画刷
|
||||
var gradientBrush = nextGenTextBlock.Foreground as LinearGradientBrush;
|
||||
|
||||
// 启动渐变色流动动画
|
||||
if (gradientBrush != null)
|
||||
{
|
||||
_ = AnimateGradientFlowAsync(gradientBrush);
|
||||
}
|
||||
|
||||
// 显示光标
|
||||
if (subtitleCursorBorder != null)
|
||||
{
|
||||
subtitleCursorBorder.IsVisible = true;
|
||||
}
|
||||
|
||||
// 打字机效果:下一代
|
||||
var nextGenText = "下一代";
|
||||
for (int i = 0; i <= nextGenText.Length; i++)
|
||||
{
|
||||
nextGenTextBlock.Text = nextGenText.Substring(0, i);
|
||||
await Task.Delay(TypingDelayMs);
|
||||
}
|
||||
|
||||
// 停顿一下
|
||||
await Task.Delay(200);
|
||||
|
||||
// 换行,光标移到第二行
|
||||
if (subtitleCursorBorder != null)
|
||||
{
|
||||
subtitleCursorBorder.IsVisible = false;
|
||||
}
|
||||
|
||||
// 打字机效果:互动信息看板
|
||||
var dashboardText = "互动信息看板";
|
||||
for (int i = 0; i <= dashboardText.Length; i++)
|
||||
{
|
||||
dashboardTextBlock.Text = dashboardText.Substring(0, i);
|
||||
await Task.Delay(TypingDelayMs);
|
||||
}
|
||||
|
||||
// 停顿一下后隐藏光标
|
||||
await Task.Delay(300);
|
||||
}
|
||||
|
||||
private async Task AnimateGradientFlowAsync(LinearGradientBrush? gradientBrush)
|
||||
{
|
||||
if (gradientBrush == null) return;
|
||||
|
||||
var stops = gradientBrush.GradientStops;
|
||||
if (stops.Count < 2) return;
|
||||
|
||||
// 获取原有的所有颜色
|
||||
var colors = new System.Collections.Generic.List<Color>();
|
||||
foreach (var stop in stops)
|
||||
{
|
||||
colors.Add(stop.Color);
|
||||
}
|
||||
|
||||
// 为了实现无缝循环流动,把第一个颜色追加到最后
|
||||
colors.Add(colors[0]);
|
||||
|
||||
// 重新分配 GradientStops
|
||||
stops.Clear();
|
||||
for (int i = 0; i < colors.Count; i++)
|
||||
{
|
||||
stops.Add(new GradientStop(colors[i], (double)i / (colors.Count - 1)));
|
||||
}
|
||||
|
||||
// 设置铺展模式,超出范围时重复
|
||||
gradientBrush.SpreadMethod = GradientSpreadMethod.Repeat;
|
||||
|
||||
double offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
offset -= 0.005; // 每次流动一小步,负数表示向右流动
|
||||
if (offset <= -1.0) offset = 0;
|
||||
|
||||
// 让渐变保持水平方向,但位置不断偏移,形成河流般的流动效果
|
||||
gradientBrush.StartPoint = new RelativePoint(offset, 0, RelativeUnit.Relative);
|
||||
gradientBrush.EndPoint = new RelativePoint(offset + 1, 0, RelativeUnit.Relative);
|
||||
|
||||
await Task.Delay(16); // 约60帧
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStartButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(2);
|
||||
}
|
||||
|
||||
// 主题选择页面按钮
|
||||
private async void OnThemeBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(1);
|
||||
}
|
||||
|
||||
private async void OnThemeNextClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(3);
|
||||
}
|
||||
|
||||
// 数据位置选择页面按钮
|
||||
private async void OnDataLocationBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(2);
|
||||
}
|
||||
|
||||
private async void OnDataLocationNextClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
|
||||
// 应用数据位置选择
|
||||
if (!_isDebugMode)
|
||||
{
|
||||
_resolver.ApplyLocationChoice(_selectedDataLocationMode, null, _migrateExistingData);
|
||||
}
|
||||
|
||||
await NavigateToStep(4);
|
||||
}
|
||||
|
||||
private async void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
_isTransitioning = true;
|
||||
|
||||
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
|
||||
|
||||
try
|
||||
{
|
||||
// 播放退出动画
|
||||
await PlayExitAnimationAsync();
|
||||
|
||||
// 完成 OOBE
|
||||
_completionSource.TrySetResult(true);
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
|
||||
Console.Error.WriteLine($"[OobeWindow] Error: {ex.Message}");
|
||||
_completionSource.TrySetResult(true);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放退出动画
|
||||
/// </summary>
|
||||
private void InitializeDataLocationStep()
|
||||
{
|
||||
if (this.FindControl<TextBlock>("SystemPathText") is { } systemPathText)
|
||||
{
|
||||
systemPathText.Text = _resolver.DefaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (this.FindControl<TextBlock>("PortablePathText") is { } portablePathText)
|
||||
{
|
||||
portablePathText.Text = _resolver.DefaultPortableDataPath;
|
||||
}
|
||||
|
||||
var canWriteToAppRoot = _resolver.IsPortableModeAllowed();
|
||||
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
|
||||
{
|
||||
portableRadio.IsEnabled = canWriteToAppRoot;
|
||||
}
|
||||
|
||||
if (!canWriteToAppRoot)
|
||||
{
|
||||
if (this.FindControl<Border>("AdminWarningBanner") is { } warningBanner)
|
||||
{
|
||||
warningBanner.IsVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (_resolver.HasExistingSystemData())
|
||||
{
|
||||
_migrateExistingData = true;
|
||||
if (this.FindControl<Border>("MigrationInfoBorder") is { } migrationInfo)
|
||||
{
|
||||
migrationInfo.IsVisible = true;
|
||||
}
|
||||
if (this.FindControl<TextBlock>("MigrationInfoText") is { } migrationText)
|
||||
{
|
||||
migrationText.Text = "检测到现有数据,选择便携模式时将自动迁移。";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectDataLocationMode(DataLocationMode mode)
|
||||
{
|
||||
_selectedDataLocationMode = mode;
|
||||
|
||||
if (this.FindControl<RadioButton>("SystemRadio") is { } systemRadio)
|
||||
{
|
||||
systemRadio.IsChecked = mode == DataLocationMode.System;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
|
||||
{
|
||||
portableRadio.IsChecked = mode == DataLocationMode.Portable;
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("SystemOptionBorder") is { } systemBorder)
|
||||
{
|
||||
systemBorder.BorderBrush = mode == DataLocationMode.System
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
systemBorder.BorderThickness = mode == DataLocationMode.System
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("PortableOptionBorder") is { } portableBorder)
|
||||
{
|
||||
portableBorder.BorderBrush = mode == DataLocationMode.Portable
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
portableBorder.BorderThickness = mode == DataLocationMode.Portable
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 主题选择方法
|
||||
private void SelectThemeMode(Services.ThemeMode mode)
|
||||
{
|
||||
_selectedThemeMode = mode;
|
||||
|
||||
// 立即应用主题到启动器
|
||||
ThemeService.ApplyTheme(mode, _selectedAccentColor);
|
||||
|
||||
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
|
||||
{
|
||||
lightModeRadio.IsChecked = mode == Services.ThemeMode.Light;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("DarkModeRadio") is { } darkModeRadio)
|
||||
{
|
||||
darkModeRadio.IsChecked = mode == Services.ThemeMode.Dark;
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
|
||||
{
|
||||
lightModeOption.BorderBrush = mode == Services.ThemeMode.Light
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
lightModeOption.BorderThickness = mode == Services.ThemeMode.Light
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
|
||||
{
|
||||
darkModeOption.BorderBrush = mode == Services.ThemeMode.Dark
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
darkModeOption.BorderThickness = mode == Services.ThemeMode.Dark
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAccentColor(string colorName, string colorValue)
|
||||
{
|
||||
_selectedAccentColor = colorValue;
|
||||
|
||||
// 更新所有颜色圆圈边框
|
||||
var colorBorders = new[] { "BlueColor", "PurpleColor", "GreenColor", "OrangeColor", "PinkColor", "TealColor" };
|
||||
foreach (var name in colorBorders)
|
||||
{
|
||||
if (this.FindControl<Border>(name) is { } border)
|
||||
{
|
||||
var isSelected = name == colorName;
|
||||
border.BorderBrush = isSelected
|
||||
? Application.Current?.Resources["TextFillColorPrimaryBrush"] as IBrush
|
||||
: null;
|
||||
border.BorderThickness = isSelected ? new Thickness(3) : new Thickness(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectMonetSource(MonetSource source)
|
||||
{
|
||||
_selectedMonetSource = source;
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromWallpaperRadio") is { } wallpaperRadio)
|
||||
{
|
||||
wallpaperRadio.IsChecked = source == MonetSource.Wallpaper;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromCustomRadio") is { } customRadio)
|
||||
{
|
||||
customRadio.IsChecked = source == MonetSource.Custom;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetDisabledRadio") is { } disabledRadio)
|
||||
{
|
||||
disabledRadio.IsChecked = source == MonetSource.Disabled;
|
||||
}
|
||||
|
||||
UpdateMonetOptionBorder("MonetFromWallpaperOption", source == MonetSource.Wallpaper);
|
||||
UpdateMonetOptionBorder("MonetFromCustomOption", source == MonetSource.Custom);
|
||||
UpdateMonetOptionBorder("MonetDisabledOption", source == MonetSource.Disabled);
|
||||
}
|
||||
|
||||
private void UpdateMonetOptionBorder(string borderName, bool isSelected)
|
||||
{
|
||||
if (this.FindControl<Border>(borderName) is { } border)
|
||||
{
|
||||
border.BorderBrush = isSelected
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
border.BorderThickness = isSelected ? new Thickness(2) : new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NavigateToStep(int step)
|
||||
{
|
||||
if (_isTransitioning || step == _currentStep) return;
|
||||
_isTransitioning = true;
|
||||
|
||||
// 获取当前步骤的控件
|
||||
Grid? currentStepControl = _currentStep switch
|
||||
{
|
||||
1 => this.FindControl<Grid>("TypingStep"),
|
||||
2 => this.FindControl<Grid>("ThemeStep"),
|
||||
3 => this.FindControl<Grid>("DataLocationStep"),
|
||||
4 => this.FindControl<Grid>("WelcomeStep"),
|
||||
_ => null
|
||||
};
|
||||
|
||||
// 获取目标步骤的控件
|
||||
Grid? nextStepControl = step switch
|
||||
{
|
||||
1 => this.FindControl<Grid>("TypingStep"),
|
||||
2 => this.FindControl<Grid>("ThemeStep"),
|
||||
3 => this.FindControl<Grid>("DataLocationStep"),
|
||||
4 => this.FindControl<Grid>("WelcomeStep"),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (currentStepControl == null || nextStepControl == null)
|
||||
{
|
||||
_isTransitioning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await AnimateOpacityAsync(currentStepControl, 1, 0, AnimationDurationMs);
|
||||
currentStepControl.IsVisible = false;
|
||||
|
||||
nextStepControl.IsVisible = true;
|
||||
nextStepControl.Opacity = 0;
|
||||
await AnimateOpacityAsync(nextStepControl, 0, 1, AnimationDurationMs);
|
||||
|
||||
_currentStep = step;
|
||||
_isTransitioning = false;
|
||||
}
|
||||
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
try
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid != null)
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接延迟后返回
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡出动画
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
Easing = new CubicEaseIn(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(200)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fadeOutAnimation.RunAsync(contentGrid);
|
||||
Console.WriteLine("[OobeWindow] Exit animation completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
|
||||
await AnimateOpacityAsync(contentGrid, 1, 0, AnimationDurationMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AnimateOpacityAsync(Control element, double from, double to, int durationMs)
|
||||
{
|
||||
var steps = 20;
|
||||
var delay = durationMs / steps;
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
var progress = (double)i / steps;
|
||||
var eased = EaseOutCubic(progress);
|
||||
element.Opacity = from + (to - from) * eased;
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - t, 3);
|
||||
private static double EaseOutQuad(double t) => 1 - Math.Pow(1 - t, 2);
|
||||
private static double EaseOutBack(double t)
|
||||
{
|
||||
const double c1 = 1.70158;
|
||||
const double c3 = c1 + 1;
|
||||
var t1 = t - 1;
|
||||
return 1 + c3 * Math.Pow(t1, 3) + c1 * Math.Pow(t1, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// 枚举定义(使用 Services 命名空间中的 ThemeMode)
|
||||
public enum MonetSource
|
||||
{
|
||||
Wallpaper,
|
||||
Custom,
|
||||
Disabled
|
||||
}
|
||||
|
||||
@@ -3,85 +3,82 @@
|
||||
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="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
Background="#0B0B0B"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:SplashWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid>
|
||||
<!-- 左上角:应用名称 -->
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="24,24,0,0"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 背景图片 -->
|
||||
<Image x:Name="BackgroundImage"
|
||||
Grid.RowSpan="2"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False"
|
||||
Opacity="0"/>
|
||||
|
||||
<!-- 底部区域:进度条和状态 -->
|
||||
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 第一行:左下角版本信息,右下角阶段文字 -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) -->
|
||||
<Border x:Name="VersionTextBorder"
|
||||
Grid.Column="0"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom">
|
||||
<TextBlock x:Name="VersionText"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Opacity="0.8"
|
||||
Text="1.0.0 (Administrate)" />
|
||||
</Border>
|
||||
|
||||
<!-- 右下角:阶段文字 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Opacity="0.8"
|
||||
Text="Initializing..." />
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
<!-- 半透明遮罩层 -->
|
||||
<Border x:Name="BackgroundOverlay"
|
||||
Grid.RowSpan="2"
|
||||
Background="#0B0B0B"
|
||||
Opacity="0.85"/>
|
||||
|
||||
<Grid Grid.Row="0"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</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"
|
||||
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" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -1,88 +1,228 @@
|
||||
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;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 启动画面窗口 - 简洁设计
|
||||
/// </summary>
|
||||
public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
private int _versionTextClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugModeOpened = false;
|
||||
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
|
||||
|
||||
private int _versionTextClickCount;
|
||||
private bool _isDebugModeOpened;
|
||||
private bool _isOpened;
|
||||
private bool _dismissed;
|
||||
|
||||
public SplashWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再绑定事件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
|
||||
|
||||
// 绑定版本文本点击事件(隐藏功能:点击5次打开开发者界面)
|
||||
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
|
||||
if (versionTextBorder is not null)
|
||||
InitializeBackgroundImage();
|
||||
|
||||
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
|
||||
{
|
||||
versionTextBorder.PointerPressed += OnVersionTextClick;
|
||||
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
|
||||
versionBorder.PointerPressed += OnVersionTextClick;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 版本文本点击事件 - 连续点击5次打开开发者界面(隐藏功能)
|
||||
/// </summary>
|
||||
private void InitializeBackgroundImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
|
||||
if (imageInfo is { IsValid: true, Bitmap: not null })
|
||||
{
|
||||
if (this.FindControl<Image>("BackgroundImage") is { } backgroundImage)
|
||||
{
|
||||
backgroundImage.Source = imageInfo.Bitmap;
|
||||
backgroundImage.IsVisible = true;
|
||||
backgroundImage.Opacity = 1;
|
||||
}
|
||||
Logger.Info("[SplashWindow] 背景图片加载成功");
|
||||
}
|
||||
else if (imageInfo is { Exists: true, IsValid: false })
|
||||
{
|
||||
Logger.Warn($"[SplashWindow] 背景图片校验失败: {imageInfo.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[SplashWindow] 加载背景图片失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isOpened)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isOpened = true;
|
||||
|
||||
Opacity = 0d;
|
||||
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DismissAsync()
|
||||
{
|
||||
if (_dismissed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dismissed = true;
|
||||
|
||||
if (!Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(async () => await DismissAsync());
|
||||
return;
|
||||
}
|
||||
|
||||
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
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 OnVersionTextClick(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_isDebugModeOpened) return;
|
||||
|
||||
_versionTextClickCount++;
|
||||
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
|
||||
if (_isDebugModeOpened)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_versionTextClickCount++;
|
||||
if (_versionTextClickCount >= DebugModeClickThreshold)
|
||||
{
|
||||
OpenDebugWindow();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开开发者调试窗口
|
||||
/// </summary>
|
||||
private async void OpenDebugWindow()
|
||||
{
|
||||
_isDebugModeOpened = true;
|
||||
Console.WriteLine("[SplashWindow] Opening debug window...");
|
||||
|
||||
try
|
||||
{
|
||||
// 加载保存的状态
|
||||
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
|
||||
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
|
||||
|
||||
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
|
||||
var debugWindow = new ErrorDebugWindow(
|
||||
ErrorWindow.CheckDevModeEnabled(),
|
||||
ErrorWindow.GetSavedCustomHostPath())
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
// 订阅窗口关闭事件以保存状态
|
||||
debugWindow.Closed += (s, e) =>
|
||||
debugWindow.Closed += (_, _) =>
|
||||
{
|
||||
Console.WriteLine("[SplashWindow] Debug window closed");
|
||||
if (debugWindow.WasAccepted)
|
||||
{
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
|
||||
debugWindow.IsDevModeEnabled,
|
||||
debugWindow.SelectedHostPath));
|
||||
}
|
||||
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
};
|
||||
@@ -91,160 +231,59 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
|
||||
Debug.WriteLine($"[SplashWindow] Failed to open debug window: {ex}");
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度和状态
|
||||
/// </summary>
|
||||
public void Report(string stage, string message)
|
||||
private async Task AnimateOpacityAsync(double from, double to, TimeSpan duration)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
await AnimateAsync(progress =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
});
|
||||
Opacity = from + ((to - from) * progress);
|
||||
}, duration, EaseOutCubic).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度(0-100)
|
||||
/// </summary>
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
private async Task AnimateAsync(Action<double> update, TimeSpan duration, Func<double, double> easing)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
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;
|
||||
}
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
});
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新状态文本
|
||||
/// </summary>
|
||||
public void UpdateStatus(string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
if (statusText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
|
||||
return;
|
||||
}
|
||||
statusText.Text = message;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告阶段和进度(0-100)
|
||||
/// </summary>
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage");
|
||||
return;
|
||||
}
|
||||
|
||||
statusText.Text = stage;
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置版本和开发代号
|
||||
/// </summary>
|
||||
public void SetVersionInfo(string version, string codename)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var versionText = this.FindControl<TextBlock>("VersionText");
|
||||
if (versionText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
|
||||
return;
|
||||
}
|
||||
versionText.Text = $"{version} ({codename})";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
if (statusText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
|
||||
return;
|
||||
}
|
||||
if (isDebugMode)
|
||||
{
|
||||
statusText.Text = "[Debug Mode] Splash Preview";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据阶段名称解析进度值
|
||||
/// </summary>
|
||||
private static int ResolveProgress(string stage)
|
||||
{
|
||||
return stage.ToLowerInvariant() switch
|
||||
{
|
||||
"initializing" => 10,
|
||||
"settings" => 25,
|
||||
"update" => 30,
|
||||
"plugins" => 50,
|
||||
"launch" => 70,
|
||||
"ui" => 65,
|
||||
"shell" => 80,
|
||||
"activation" => 90,
|
||||
"ready" => 100,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static double EaseOutCubic(double value)
|
||||
{
|
||||
var inverse = 1d - value;
|
||||
return 1d - (inverse * inverse * inverse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
|
||||
<assemblyIdentity version="0.0.0.0" name="LanMountainDesktop.Launcher"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public static class AppVersionProvider
|
||||
{
|
||||
private const string DefaultVersion = "0.0.0";
|
||||
private const string DefaultCodename = "Administrate";
|
||||
private const string VersionFileName = "version.json";
|
||||
|
||||
public static AppVersionInfo ResolveForCurrentProcess(
|
||||
IReadOnlyList<string>? commandLineArgs = null,
|
||||
string? executablePath = null,
|
||||
string? deploymentDirectory = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
return Resolve(
|
||||
packageRoot: LauncherRuntimeMetadata.GetPackageRoot(args),
|
||||
deploymentDirectory: deploymentDirectory ?? AppContext.BaseDirectory,
|
||||
executablePath: executablePath ?? Environment.ProcessPath,
|
||||
versionOverride: LauncherRuntimeMetadata.GetForwardedVersion(args),
|
||||
codenameOverride: LauncherRuntimeMetadata.GetForwardedCodename(args));
|
||||
}
|
||||
|
||||
public static AppVersionInfo ResolveFromDeploymentDirectory(
|
||||
string? deploymentDirectory,
|
||||
string? executablePath = null,
|
||||
string? versionOverride = null,
|
||||
string? codenameOverride = null)
|
||||
{
|
||||
return Resolve(
|
||||
packageRoot: null,
|
||||
deploymentDirectory: deploymentDirectory,
|
||||
executablePath: executablePath,
|
||||
versionOverride: versionOverride,
|
||||
codenameOverride: codenameOverride);
|
||||
}
|
||||
|
||||
public static AppVersionInfo ResolveFromPackageRoot(
|
||||
string? packageRoot,
|
||||
string executableName,
|
||||
string? versionOverride = null,
|
||||
string? codenameOverride = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packageRoot))
|
||||
{
|
||||
return CreateFallback(versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var deploymentDirectory = FindCurrentDeploymentDirectory(packageRoot, executableName);
|
||||
var executablePath = !string.IsNullOrWhiteSpace(deploymentDirectory)
|
||||
? Path.Combine(deploymentDirectory, executableName)
|
||||
: null;
|
||||
|
||||
return Resolve(
|
||||
packageRoot: packageRoot,
|
||||
deploymentDirectory: deploymentDirectory,
|
||||
executablePath: executablePath,
|
||||
versionOverride: versionOverride,
|
||||
codenameOverride: codenameOverride);
|
||||
}
|
||||
|
||||
public static AppVersionInfo Resolve(
|
||||
string? packageRoot,
|
||||
string? deploymentDirectory,
|
||||
string? executablePath,
|
||||
string? versionOverride = null,
|
||||
string? codenameOverride = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(versionOverride))
|
||||
{
|
||||
return Create(versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var normalizedDeploymentDirectory = NormalizeExistingDirectory(deploymentDirectory)
|
||||
?? ResolveDeploymentFromPackageRoot(packageRoot, executablePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedDeploymentDirectory) &&
|
||||
TryReadVersionFile(normalizedDeploymentDirectory, out var fileInfo))
|
||||
{
|
||||
return OverrideMissingParts(fileInfo, versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var normalizedExecutablePath = NormalizeExistingFile(executablePath)
|
||||
?? ResolveExecutableFromDeployment(normalizedDeploymentDirectory, executablePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath) &&
|
||||
TryReadExecutableVersion(normalizedExecutablePath, out var executableInfo))
|
||||
{
|
||||
return OverrideMissingParts(executableInfo, versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var versionFromDirectory = TryParseVersionFromDeploymentDirectory(normalizedDeploymentDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(versionFromDirectory))
|
||||
{
|
||||
return Create(versionFromDirectory, codenameOverride);
|
||||
}
|
||||
|
||||
return CreateFallback(versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
public static string NormalizeVersionText(string? rawValue, string fallback = DefaultVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var normalized = TrimSurroundingQuotes(rawValue)
|
||||
.Split('+', 2, StringSplitOptions.TrimEntries)[0]
|
||||
.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? fallback
|
||||
: normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
|
||||
{
|
||||
var normalized = TrimSurroundingQuotes(rawValue);
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? fallback
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static AppVersionInfo OverrideMissingParts(
|
||||
AppVersionInfo source,
|
||||
string? versionOverride,
|
||||
string? codenameOverride)
|
||||
{
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(versionOverride ?? source.Version),
|
||||
Codename = NormalizeCodename(codenameOverride ?? source.Codename)
|
||||
};
|
||||
}
|
||||
|
||||
private static AppVersionInfo CreateFallback(string? versionOverride, string? codenameOverride)
|
||||
{
|
||||
return Create(versionOverride ?? DefaultVersion, codenameOverride ?? DefaultCodename);
|
||||
}
|
||||
|
||||
private static AppVersionInfo Create(string version, string? codename)
|
||||
{
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(version),
|
||||
Codename = NormalizeCodename(codename)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadVersionFile(string deploymentDirectory, out AppVersionInfo info)
|
||||
{
|
||||
info = default!;
|
||||
var versionFilePath = Path.Combine(deploymentDirectory, VersionFileName);
|
||||
if (!File.Exists(versionFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(versionFilePath));
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var version = ReadStringProperty(root, nameof(AppVersionInfo.Version));
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var codename = ReadStringProperty(root, nameof(AppVersionInfo.Codename));
|
||||
info = new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(version),
|
||||
Codename = NormalizeCodename(codename)
|
||||
};
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadExecutableVersion(string executablePath, out AppVersionInfo info)
|
||||
{
|
||||
info = default!;
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = FileVersionInfo.GetVersionInfo(executablePath);
|
||||
var version = NormalizeVersionText(fileInfo.ProductVersion);
|
||||
if (string.Equals(version, DefaultVersion, StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(fileInfo.FileVersion))
|
||||
{
|
||||
version = NormalizeVersionText(fileInfo.FileVersion);
|
||||
}
|
||||
|
||||
if (string.Equals(version, DefaultVersion, StringComparison.Ordinal))
|
||||
{
|
||||
var assemblyNameVersion = AssemblyName.GetAssemblyName(executablePath).Version;
|
||||
if (assemblyNameVersion is not null)
|
||||
{
|
||||
version = NormalizeVersionText(assemblyNameVersion.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
info = new AppVersionInfo
|
||||
{
|
||||
Version = version,
|
||||
Codename = DefaultCodename
|
||||
};
|
||||
return !string.Equals(version, DefaultVersion, StringComparison.Ordinal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveDeploymentFromPackageRoot(string? packageRoot, string? executablePath)
|
||||
{
|
||||
var normalizedPackageRoot = NormalizeExistingDirectory(packageRoot);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPackageRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedExecutablePath = NormalizeExistingFile(executablePath);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath))
|
||||
{
|
||||
var executableDirectory = NormalizeExistingDirectory(Path.GetDirectoryName(normalizedExecutablePath));
|
||||
if (!string.IsNullOrWhiteSpace(executableDirectory) &&
|
||||
executableDirectory.StartsWith(normalizedPackageRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return executableDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
var executableName = Path.GetFileName(normalizedExecutablePath);
|
||||
return FindCurrentDeploymentDirectory(normalizedPackageRoot, executableName);
|
||||
}
|
||||
|
||||
private static string? ResolveExecutableFromDeployment(string? deploymentDirectory, string? executablePath)
|
||||
{
|
||||
var normalizedExecutablePath = NormalizeExistingFile(executablePath);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath))
|
||||
{
|
||||
return normalizedExecutablePath;
|
||||
}
|
||||
|
||||
var normalizedDeploymentDirectory = NormalizeExistingDirectory(deploymentDirectory);
|
||||
if (string.IsNullOrWhiteSpace(normalizedDeploymentDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidateName in GetExecutableCandidates(executablePath))
|
||||
{
|
||||
var candidatePath = Path.Combine(normalizedDeploymentDirectory, candidateName);
|
||||
if (File.Exists(candidatePath))
|
||||
{
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetExecutableCandidates(string? executablePath)
|
||||
{
|
||||
var fileName = Path.GetFileName(executablePath);
|
||||
if (!string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return [fileName];
|
||||
}
|
||||
|
||||
return OperatingSystem.IsWindows()
|
||||
? ["LanMountainDesktop.exe"]
|
||||
: ["LanMountainDesktop"];
|
||||
}
|
||||
|
||||
private static string? FindCurrentDeploymentDirectory(string packageRoot, string? executableName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Directory.GetDirectories(packageRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
IsCurrent = File.Exists(Path.Combine(path, ".current")),
|
||||
HasExecutable = string.IsNullOrWhiteSpace(executableName) || File.Exists(Path.Combine(path, executableName)),
|
||||
Version = TryParseVersionFromDeploymentDirectory(path)
|
||||
})
|
||||
.Where(item => item.HasExecutable)
|
||||
.OrderByDescending(item => item.IsCurrent)
|
||||
.ThenByDescending(item => item.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return candidates.FirstOrDefault()?.Path;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryParseVersionFromDeploymentDirectory(string? deploymentDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deploymentDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var directoryName = Path.GetFileName(deploymentDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.IsNullOrWhiteSpace(directoryName) ||
|
||||
!directoryName.StartsWith("app-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var remaining = directoryName["app-".Length..];
|
||||
var segments = remaining.Split('-', StringSplitOptions.RemoveEmptyEntries);
|
||||
return segments.Length > 0
|
||||
? NormalizeVersionText(segments[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return Directory.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return File.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadStringProperty(JsonElement root, string propertyName)
|
||||
{
|
||||
foreach (var property in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
|
||||
property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.Value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TrimSurroundingQuotes(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = rawValue.Trim();
|
||||
while (normalized.Length >= 2)
|
||||
{
|
||||
var first = normalized[0];
|
||||
var last = normalized[^1];
|
||||
if ((first == '\'' && last == '\'') ||
|
||||
(first == '"' && last == '"'))
|
||||
{
|
||||
normalized = normalized[1..^1].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ public enum StartupStage
|
||||
Initializing,
|
||||
LoadingSettings,
|
||||
LoadingPlugins,
|
||||
TrayReady,
|
||||
InitializingUI,
|
||||
ShellInitialized,
|
||||
BackgroundReady,
|
||||
DesktopVisible,
|
||||
ActivationRedirected,
|
||||
ActivationFailed,
|
||||
@@ -35,4 +37,10 @@ public static class LauncherIpcConstants
|
||||
public const string VersionEnvVar = "LMD_VERSION";
|
||||
|
||||
public const string CodenameEnvVar = "LMD_CODENAME";
|
||||
|
||||
public const string LaunchSourceOptionName = "launch-source";
|
||||
|
||||
public const string RestartParentPidOptionName = "restart-parent-pid";
|
||||
|
||||
public const string RestartPresentationOptionName = "restart-presentation";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public enum RestartPresentationMode
|
||||
{
|
||||
Foreground = 0,
|
||||
Minimized = 1,
|
||||
Tray = 2
|
||||
}
|
||||
|
||||
public static class LauncherRuntimeMetadata
|
||||
{
|
||||
public static string? GetOptionValue(string key, IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var longPrefix = $"--{key}";
|
||||
|
||||
for (var index = 0; index < args.Count; index++)
|
||||
{
|
||||
var argument = args[index];
|
||||
if (!argument.StartsWith(longPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(argument, longPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
return "true";
|
||||
}
|
||||
|
||||
if (argument.Length > longPrefix.Length && argument[longPrefix.Length] == '=')
|
||||
{
|
||||
return argument[(longPrefix.Length + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool HasOption(string key, IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(GetOptionValue(key, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetPackageRoot(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.PackageRootEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.PackageRootEnvVar, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetForwardedVersion(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.VersionEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.VersionEnvVar, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetForwardedCodename(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.CodenameEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.CodenameEnvVar, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetLaunchSource(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return GetOptionValue(LauncherIpcConstants.LaunchSourceOptionName, commandLineArgs);
|
||||
}
|
||||
|
||||
public static int? GetLauncherProcessId(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
var rawValue = FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.LauncherPidEnvVar, commandLineArgs));
|
||||
|
||||
return TryParsePositiveInt(rawValue);
|
||||
}
|
||||
|
||||
public static int? GetRestartParentProcessId(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
var rawValue = GetOptionValue(LauncherIpcConstants.RestartParentPidOptionName, commandLineArgs);
|
||||
return TryParsePositiveInt(rawValue);
|
||||
}
|
||||
|
||||
public static RestartPresentationMode? GetRestartPresentationMode(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
var rawValue = GetOptionValue(LauncherIpcConstants.RestartPresentationOptionName, commandLineArgs);
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return NormalizeRestartPresentation(rawValue);
|
||||
}
|
||||
|
||||
public static string FormatRestartPresentation(RestartPresentationMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
RestartPresentationMode.Minimized => "minimized",
|
||||
RestartPresentationMode.Tray => "tray",
|
||||
_ => "foreground"
|
||||
};
|
||||
}
|
||||
|
||||
public static RestartPresentationMode NormalizeRestartPresentation(string rawValue)
|
||||
{
|
||||
return rawValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"minimized" => RestartPresentationMode.Minimized,
|
||||
"tray" => RestartPresentationMode.Tray,
|
||||
_ => RestartPresentationMode.Foreground
|
||||
};
|
||||
}
|
||||
|
||||
private static int? TryParsePositiveInt(string? rawValue)
|
||||
{
|
||||
return int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue) &&
|
||||
parsedValue > 0
|
||||
? parsedValue
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,231 +1,85 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
public enum LoadingItemType
|
||||
{
|
||||
/// <summary>
|
||||
/// 系统初始化
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// 设置加载
|
||||
/// </summary>
|
||||
Settings,
|
||||
|
||||
/// <summary>
|
||||
/// 插件
|
||||
/// </summary>
|
||||
Plugin,
|
||||
|
||||
/// <summary>
|
||||
/// 组件
|
||||
/// </summary>
|
||||
Component,
|
||||
|
||||
/// <summary>
|
||||
/// 资源
|
||||
/// </summary>
|
||||
Resource,
|
||||
|
||||
/// <summary>
|
||||
/// 数据
|
||||
/// </summary>
|
||||
Data,
|
||||
|
||||
/// <summary>
|
||||
/// 网络请求
|
||||
/// </summary>
|
||||
Network,
|
||||
|
||||
/// <summary>
|
||||
/// 其他
|
||||
/// </summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态
|
||||
/// </summary>
|
||||
public enum LoadingState
|
||||
{
|
||||
/// <summary>
|
||||
/// 等待中
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// 进行中
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// 已完成
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// 失败
|
||||
/// </summary>
|
||||
Delayed,
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// 已取消
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// 超时
|
||||
/// </summary>
|
||||
Timeout
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载项信息
|
||||
/// </summary>
|
||||
public record LoadingItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载项唯一标识
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
|
||||
public LoadingItemType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项名称
|
||||
/// </summary>
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项描述
|
||||
/// </summary>
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前状态
|
||||
/// </summary>
|
||||
|
||||
public LoadingState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进度百分比 (0-100)
|
||||
/// </summary>
|
||||
|
||||
public int ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(当 State 为 Failed 时)
|
||||
/// </summary>
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset? StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset? EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预计剩余时间(秒)
|
||||
/// </summary>
|
||||
|
||||
public int? EstimatedRemainingSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 子加载项
|
||||
/// </summary>
|
||||
|
||||
public List<LoadingItem>? Children { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 额外数据
|
||||
/// </summary>
|
||||
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态更新消息
|
||||
/// </summary>
|
||||
public record LoadingStateMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前启动阶段
|
||||
/// </summary>
|
||||
public StartupStage Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度百分比 (0-100)
|
||||
/// </summary>
|
||||
|
||||
public int OverallProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前活动的加载项
|
||||
/// </summary>
|
||||
|
||||
public List<LoadingItem> ActiveItems { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 已完成的加载项数量
|
||||
/// </summary>
|
||||
|
||||
public int CompletedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总加载项数量
|
||||
/// </summary>
|
||||
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否有错误
|
||||
/// </summary>
|
||||
|
||||
public bool HasErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误消息列表
|
||||
/// </summary>
|
||||
|
||||
public List<string>? ErrorMessages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 详细的加载进度消息(用于实时更新)
|
||||
/// </summary>
|
||||
public record DetailedProgressMessage : StartupProgressMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前加载项
|
||||
/// </summary>
|
||||
public LoadingItem? CurrentItem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所有加载项
|
||||
/// </summary>
|
||||
|
||||
public List<LoadingItem>? AllItems { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为主要更新
|
||||
/// </summary>
|
||||
|
||||
public bool IsMajorUpdate { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public enum StartupVisualMode
|
||||
{
|
||||
Fade,
|
||||
StaticSplash,
|
||||
SlideSplash
|
||||
}
|
||||
|
||||
public readonly record struct StartupVisualPreferences(
|
||||
bool EnableFadeTransition,
|
||||
bool EnableSlideTransition)
|
||||
{
|
||||
public static StartupVisualPreferences Default => new(true, false);
|
||||
|
||||
public StartupVisualPreferences Normalize()
|
||||
{
|
||||
if (EnableSlideTransition)
|
||||
{
|
||||
return new StartupVisualPreferences(false, true);
|
||||
}
|
||||
|
||||
return new StartupVisualPreferences(EnableFadeTransition, false);
|
||||
}
|
||||
|
||||
public StartupVisualMode Mode => Normalize() switch
|
||||
{
|
||||
{ EnableSlideTransition: true } => StartupVisualMode.SlideSplash,
|
||||
{ EnableFadeTransition: false } => StartupVisualMode.StaticSplash,
|
||||
_ => StartupVisualMode.Fade
|
||||
};
|
||||
}
|
||||
|
||||
public static class StartupVisualPreferencesResolver
|
||||
{
|
||||
public static StartupVisualPreferences Resolve(string? settingsPath = null)
|
||||
{
|
||||
var resolvedPath = string.IsNullOrWhiteSpace(settingsPath)
|
||||
? GetDefaultSettingsPath()
|
||||
: settingsPath!;
|
||||
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
return StartupVisualPreferences.Default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(resolvedPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var root = document.RootElement;
|
||||
|
||||
var enableFade = TryGetBoolean(root, "enableFadeTransition") ?? true;
|
||||
var enableSlide = TryGetBoolean(root, "enableSlideTransition") ?? false;
|
||||
return FromFlags(enableFade, enableSlide);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StartupVisualPreferences.Default;
|
||||
}
|
||||
}
|
||||
|
||||
public static StartupVisualPreferences FromFlags(bool enableFadeTransition, bool enableSlideTransition)
|
||||
{
|
||||
return new StartupVisualPreferences(enableFadeTransition, enableSlideTransition).Normalize();
|
||||
}
|
||||
|
||||
public static string GetDefaultSettingsPath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(appData, "LanMountainDesktop", "settings.json");
|
||||
}
|
||||
|
||||
private static bool? TryGetBoolean(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String when bool.TryParse(property.GetString(), out var value) => value,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,16 @@ namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicShellControlService
|
||||
{
|
||||
Task<PublicShellStatus> GetShellStatusAsync();
|
||||
|
||||
Task<bool> ActivateMainWindowAsync();
|
||||
|
||||
Task<PublicShellActivationResult> ActivateMainWindowWithStatusAsync();
|
||||
|
||||
Task<PublicTrayStatus> EnsureTrayReadyAsync();
|
||||
|
||||
Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync();
|
||||
|
||||
Task<bool> OpenSettingsAsync(string? pageTag = null);
|
||||
|
||||
Task<bool> RestartAsync();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
public sealed record PublicShellStatus(
|
||||
int ProcessId,
|
||||
DateTimeOffset StartedAtUtc,
|
||||
string LaunchSource,
|
||||
string ShellState,
|
||||
bool MainWindowCreated,
|
||||
bool MainWindowVisible,
|
||||
bool MainWindowOpened,
|
||||
bool DesktopVisible,
|
||||
bool PublicIpcReady,
|
||||
PublicTrayStatus Tray,
|
||||
PublicTaskbarStatus Taskbar);
|
||||
|
||||
public sealed record PublicTrayStatus(
|
||||
string State,
|
||||
bool IsReady,
|
||||
bool HasIcon,
|
||||
bool HasMenu,
|
||||
bool IsVisible,
|
||||
int ConsecutiveRecoveryFailures);
|
||||
|
||||
public sealed record PublicTaskbarStatus(
|
||||
bool RequestedBySettings,
|
||||
bool MainWindowExists,
|
||||
bool MainWindowShowInTaskbar,
|
||||
bool MainWindowVisible,
|
||||
bool MainWindowMinimized,
|
||||
bool IsUsable);
|
||||
|
||||
public sealed record PublicShellActivationResult(
|
||||
bool Accepted,
|
||||
string Code,
|
||||
string Message,
|
||||
PublicShellStatus Status);
|
||||
83
LanMountainDesktop.Tests/AppVersionProviderTests.cs
Normal file
83
LanMountainDesktop.Tests/AppVersionProviderTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class AppVersionProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveFromPackageRoot_WhenVersionJsonExists_UsesVersionFile()
|
||||
{
|
||||
using var temp = TemporaryPackage.Create();
|
||||
temp.CreateDeployment("app-0.8.5.7", """
|
||||
{"Version":"0.8.5.7","Codename":"Administrate"}
|
||||
""");
|
||||
|
||||
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
|
||||
|
||||
Assert.Equal("0.8.5.7", info.Version);
|
||||
Assert.Equal("Administrate", info.Codename);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFromPackageRoot_WhenVersionJsonIsMissing_FallsBackToDeploymentDirectory()
|
||||
{
|
||||
using var temp = TemporaryPackage.Create();
|
||||
temp.CreateDeployment("app-0.8.5.7");
|
||||
|
||||
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
|
||||
|
||||
Assert.Equal("0.8.5.7", info.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFromPackageRoot_WhenVersionJsonContainsQuotedValues_NormalizesValues()
|
||||
{
|
||||
using var temp = TemporaryPackage.Create();
|
||||
temp.CreateDeployment("app-1.2.3", """
|
||||
{"Version":"'1.2.3'","Codename":"'Administrate'"}
|
||||
""");
|
||||
|
||||
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
|
||||
|
||||
Assert.Equal("1.2.3", info.Version);
|
||||
Assert.Equal("Administrate", info.Codename);
|
||||
}
|
||||
|
||||
private sealed class TemporaryPackage : IDisposable
|
||||
{
|
||||
private TemporaryPackage(string root)
|
||||
{
|
||||
Root = root;
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public static TemporaryPackage Create()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.VersionTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
return new TemporaryPackage(root);
|
||||
}
|
||||
|
||||
public void CreateDeployment(string name, string? versionJson = null)
|
||||
{
|
||||
var deployment = Path.Combine(Root, name);
|
||||
Directory.CreateDirectory(deployment);
|
||||
File.WriteAllText(Path.Combine(deployment, "LanMountainDesktop.exe"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
|
||||
if (versionJson is not null)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(deployment, "version.json"), versionJson);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Root))
|
||||
{
|
||||
Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
LanMountainDesktop.Tests/DeploymentLocatorTests.cs
Normal file
43
LanMountainDesktop.Tests/DeploymentLocatorTests.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
[Collection("LauncherDebugSettingsStore")]
|
||||
public sealed class DeploymentLocatorTests : IDisposable
|
||||
{
|
||||
private readonly string _appRoot;
|
||||
private readonly string _configRoot;
|
||||
|
||||
public DeploymentLocatorTests()
|
||||
{
|
||||
var testRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DeploymentLocatorTests", Guid.NewGuid().ToString("N"));
|
||||
_appRoot = Path.Combine(testRoot, "app-root");
|
||||
_configRoot = Path.Combine(testRoot, "config");
|
||||
Directory.CreateDirectory(_appRoot);
|
||||
Directory.CreateDirectory(_configRoot);
|
||||
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _configRoot;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHostExecutable_WhenSavedDebugPathIsMalformed_DoesNotThrow()
|
||||
{
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, "bad\0path"));
|
||||
|
||||
var locator = new DeploymentLocator(_appRoot);
|
||||
var result = locator.ResolveHostExecutable(CommandContext.FromArgs(["launch", "--debug"]));
|
||||
|
||||
Assert.NotEqual("debug_saved_custom_path", result.ResolutionSource);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
|
||||
var testRoot = Directory.GetParent(_appRoot)?.FullName;
|
||||
if (!string.IsNullOrWhiteSpace(testRoot) && Directory.Exists(testRoot))
|
||||
{
|
||||
Directory.Delete(testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
100
LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs
Normal file
100
LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class HostLaunchPlanBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _testRoot;
|
||||
|
||||
public HostLaunchPlanBuilderTests()
|
||||
{
|
||||
_testRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.HostLaunchPlanTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_testRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesPackageRootAsWorkingDirectory_ForPublishedDeployment()
|
||||
{
|
||||
var packageRoot = Path.Combine(_testRoot, "package-root");
|
||||
var deployment = CreateDeployment(packageRoot, "app-0.8.5.7");
|
||||
var resultPath = Path.Combine(_testRoot, "launcher-result.json");
|
||||
var context = CommandContext.FromArgs(
|
||||
[
|
||||
"launch",
|
||||
"--app-root", packageRoot,
|
||||
"--result", resultPath,
|
||||
"--launch-source", "postinstall",
|
||||
"--custom-host-arg", "custom-value"
|
||||
]);
|
||||
var locator = new DeploymentLocator(packageRoot);
|
||||
var resolution = locator.ResolveHostExecutable(context);
|
||||
|
||||
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
|
||||
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.PackageRoot);
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
|
||||
Assert.Equal(Path.Combine(deployment, GetExecutableName()), plan.HostPath);
|
||||
Assert.Contains("--launch-source", plan.Arguments);
|
||||
Assert.Contains("postinstall", plan.Arguments);
|
||||
Assert.Contains("--custom-host-arg", plan.Arguments);
|
||||
Assert.Contains("custom-value", plan.Arguments);
|
||||
Assert.DoesNotContain("--app-root", plan.Arguments);
|
||||
Assert.DoesNotContain(packageRoot, plan.Arguments);
|
||||
Assert.DoesNotContain("--result", plan.Arguments);
|
||||
Assert.DoesNotContain(resultPath, plan.Arguments);
|
||||
Assert.Contains($"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}", plan.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_KeepsPathsWithSpacesAsSingleArgumentListTokens()
|
||||
{
|
||||
var packageRoot = Path.Combine(_testRoot, "package root with spaces");
|
||||
CreateDeployment(packageRoot, "app-0.8.5.7");
|
||||
var context = CommandContext.FromArgs(["launch", "--app-root", packageRoot]);
|
||||
var locator = new DeploymentLocator(packageRoot);
|
||||
var resolution = locator.ResolveHostExecutable(context);
|
||||
|
||||
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
|
||||
|
||||
var packageRootArgument = $"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}";
|
||||
Assert.Contains(packageRootArgument, plan.Arguments);
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar]);
|
||||
Assert.DoesNotContain(plan.Arguments, argument => argument.StartsWith("\"", StringComparison.Ordinal));
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
|
||||
}
|
||||
|
||||
private static string CreateDeployment(string packageRoot, string deploymentName)
|
||||
{
|
||||
var deployment = Path.Combine(packageRoot, deploymentName);
|
||||
Directory.CreateDirectory(deployment);
|
||||
File.WriteAllText(Path.Combine(deployment, GetExecutableName()), string.Empty);
|
||||
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
|
||||
File.WriteAllText(
|
||||
Path.Combine(deployment, "version.json"),
|
||||
"""
|
||||
{"Version":"0.8.5.7","Codename":"Administrate"}
|
||||
""");
|
||||
return deployment;
|
||||
}
|
||||
|
||||
private static string GetExecutableName()
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.exe"
|
||||
: "LanMountainDesktop";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testRoot))
|
||||
{
|
||||
Directory.Delete(_testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
LanMountainDesktop.Tests/HostShutdownGateTests.cs
Normal file
48
LanMountainDesktop.Tests/HostShutdownGateTests.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class HostShutdownGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Submit_WhenFirstExitRequest_AcceptsAndRecordsExit()
|
||||
{
|
||||
var gate = new HostShutdownGate();
|
||||
|
||||
var submission = gate.Submit(HostShutdownMode.Exit);
|
||||
|
||||
Assert.True(submission.Accepted);
|
||||
Assert.True(submission.IsFirstSubmission);
|
||||
Assert.Equal(HostShutdownMode.Exit, submission.EffectiveMode);
|
||||
Assert.True(gate.IsShutdownRequested);
|
||||
Assert.Equal(HostShutdownMode.Exit, gate.EffectiveMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_WhenDuplicateSameMode_AcceptsButDoesNotExecuteAgain()
|
||||
{
|
||||
var gate = new HostShutdownGate();
|
||||
gate.Submit(HostShutdownMode.Exit);
|
||||
|
||||
var duplicate = gate.Submit(HostShutdownMode.Exit);
|
||||
|
||||
Assert.True(duplicate.Accepted);
|
||||
Assert.False(duplicate.IsFirstSubmission);
|
||||
Assert.Equal(HostShutdownMode.Exit, duplicate.EffectiveMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_WhenExitArrivesAfterRestart_DoesNotOverwriteRestart()
|
||||
{
|
||||
var gate = new HostShutdownGate();
|
||||
gate.Submit(HostShutdownMode.Restart);
|
||||
|
||||
var conflictingExit = gate.Submit(HostShutdownMode.Exit);
|
||||
|
||||
Assert.False(conflictingExit.Accepted);
|
||||
Assert.False(conflictingExit.IsFirstSubmission);
|
||||
Assert.Equal(HostShutdownMode.Restart, conflictingExit.EffectiveMode);
|
||||
Assert.Equal(HostShutdownMode.Restart, gate.EffectiveMode);
|
||||
}
|
||||
}
|
||||
126
LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs
Normal file
126
LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class LauncherCoordinatorRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryReserveCoordinator_WhenActiveCoordinatorExists_ReturnsActiveAttempt()
|
||||
{
|
||||
using var temp = TemporaryAttemptState.Create();
|
||||
var firstRegistry = new StartupAttemptRegistry(temp.StatePath);
|
||||
var secondRegistry = new StartupAttemptRegistry(temp.StatePath);
|
||||
|
||||
Assert.True(firstRegistry.TryReserveCoordinator(
|
||||
"normal",
|
||||
"Foreground",
|
||||
"pipe-a",
|
||||
out var firstAttempt,
|
||||
out var firstActive));
|
||||
Assert.Null(firstActive);
|
||||
|
||||
Assert.False(secondRegistry.TryReserveCoordinator(
|
||||
"normal",
|
||||
"Foreground",
|
||||
"pipe-b",
|
||||
out _,
|
||||
out var secondActive));
|
||||
|
||||
Assert.NotNull(secondActive);
|
||||
Assert.Equal(firstAttempt.AttemptId, secondActive.AttemptId);
|
||||
Assert.Equal("pipe-a", secondActive.CoordinatorPipeName);
|
||||
Assert.Equal(Environment.ProcessId, secondActive.CoordinatorPid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserveCoordinator_WhenHeartbeatIsStale_TakesOverAttempt()
|
||||
{
|
||||
using var temp = TemporaryAttemptState.Create();
|
||||
var firstRegistry = new StartupAttemptRegistry(temp.StatePath);
|
||||
var secondRegistry = new StartupAttemptRegistry(temp.StatePath);
|
||||
|
||||
Assert.True(firstRegistry.TryReserveCoordinator(
|
||||
"normal",
|
||||
"Foreground",
|
||||
"pipe-a",
|
||||
out var firstAttempt,
|
||||
out _));
|
||||
temp.SetHeartbeat(DateTimeOffset.UtcNow.AddSeconds(-30));
|
||||
|
||||
Assert.True(secondRegistry.TryReserveCoordinator(
|
||||
"normal",
|
||||
"Foreground",
|
||||
"pipe-b",
|
||||
out var reservedAttempt,
|
||||
out var activeAttempt));
|
||||
|
||||
Assert.Null(activeAttempt);
|
||||
Assert.Equal(firstAttempt.AttemptId, reservedAttempt.AttemptId);
|
||||
Assert.Equal("pipe-b", reservedAttempt.CoordinatorPipeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssignOwnedHostProcess_ClearsReservedBeforeHostStart()
|
||||
{
|
||||
using var temp = TemporaryAttemptState.Create();
|
||||
var registry = new StartupAttemptRegistry(temp.StatePath);
|
||||
|
||||
Assert.True(registry.TryReserveCoordinator(
|
||||
"normal",
|
||||
"Foreground",
|
||||
"pipe-a",
|
||||
out var reservedAttempt,
|
||||
out _));
|
||||
Assert.True(reservedAttempt.ReservedBeforeHostStart);
|
||||
|
||||
var assignedAttempt = registry.AssignOwnedHostProcess(
|
||||
Environment.ProcessId,
|
||||
StartupStage.Initializing,
|
||||
"host assigned");
|
||||
|
||||
Assert.Equal(Environment.ProcessId, assignedAttempt.HostPid);
|
||||
Assert.False(assignedAttempt.ReservedBeforeHostStart);
|
||||
}
|
||||
|
||||
private sealed class TemporaryAttemptState : IDisposable
|
||||
{
|
||||
private TemporaryAttemptState(string directory)
|
||||
{
|
||||
Directory = directory;
|
||||
StatePath = Path.Combine(directory, "startup-attempt.json");
|
||||
}
|
||||
|
||||
public string Directory { get; }
|
||||
|
||||
public string StatePath { get; }
|
||||
|
||||
public static TemporaryAttemptState Create()
|
||||
{
|
||||
var directory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.LauncherCoordinatorTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
System.IO.Directory.CreateDirectory(directory);
|
||||
return new TemporaryAttemptState(directory);
|
||||
}
|
||||
|
||||
public void SetHeartbeat(DateTimeOffset heartbeatAtUtc)
|
||||
{
|
||||
var node = JsonNode.Parse(File.ReadAllText(StatePath))!.AsObject();
|
||||
node["heartbeatAtUtc"] = heartbeatAtUtc.ToString("O");
|
||||
File.WriteAllText(StatePath, node.ToJsonString());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.IO.Directory.Exists(Directory))
|
||||
{
|
||||
System.IO.Directory.Delete(Directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs
Normal file
50
LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
[Collection("LauncherDebugSettingsStore")]
|
||||
public sealed class LauncherDebugSettingsStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDirectory;
|
||||
|
||||
public LauncherDebugSettingsStoreTests()
|
||||
{
|
||||
_tempDirectory = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DebugSettingsTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _tempDirectory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenOnlyLegacyFilesExist_ReadsLegacySettings()
|
||||
{
|
||||
var customPath = Path.Combine(_tempDirectory, "legacy-host.exe");
|
||||
File.WriteAllText(Path.Combine(_tempDirectory, "devmode.config"), "1");
|
||||
File.WriteAllText(Path.Combine(_tempDirectory, "custom-host-path.config"), customPath);
|
||||
|
||||
var settings = LauncherDebugSettingsStore.Load();
|
||||
|
||||
Assert.True(settings.DevModeEnabled);
|
||||
Assert.Equal(customPath, settings.CustomHostPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_WritesNewSettingsFiles()
|
||||
{
|
||||
var customPath = Path.Combine(_tempDirectory, "host.exe");
|
||||
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, customPath));
|
||||
|
||||
Assert.Equal("True", File.ReadAllText(Path.Combine(_tempDirectory, "dev-mode.flag")).Trim());
|
||||
Assert.Equal(customPath, File.ReadAllText(Path.Combine(_tempDirectory, "custom-host-path.txt")).Trim());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
|
||||
if (Directory.Exists(_tempDirectory))
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SettingsWindowPlacementHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveWorkingArea_PrefersReferenceScreen()
|
||||
{
|
||||
var referenceArea = new PixelRect(1920, 0, 2560, 1440);
|
||||
var primaryArea = new PixelRect(0, 0, 1920, 1080);
|
||||
|
||||
var result = SettingsWindowPlacementHelper.ResolveWorkingArea(
|
||||
referenceArea,
|
||||
primaryArea,
|
||||
fallbackWindowWidth: 1120,
|
||||
fallbackWindowHeight: 760);
|
||||
|
||||
Assert.Equal(referenceArea, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWorkingArea_FallsBackToPrimaryScreenWhenReferenceIsMissing()
|
||||
{
|
||||
var primaryArea = new PixelRect(0, 0, 1920, 1080);
|
||||
|
||||
var result = SettingsWindowPlacementHelper.ResolveWorkingArea(
|
||||
referenceWorkingArea: null,
|
||||
primaryWorkingArea: primaryArea,
|
||||
fallbackWindowWidth: 1120,
|
||||
fallbackWindowHeight: 760);
|
||||
|
||||
Assert.Equal(primaryArea, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateCenteredPosition_ReturnsCenteredPointInsideWorkingArea()
|
||||
{
|
||||
var workingArea = new PixelRect(1920, 40, 2560, 1400);
|
||||
|
||||
var result = SettingsWindowPlacementHelper.CalculateCenteredPosition(
|
||||
workingArea,
|
||||
windowWidth: 1120,
|
||||
windowHeight: 760);
|
||||
|
||||
Assert.Equal(new PixelPoint(2640, 360), result);
|
||||
}
|
||||
}
|
||||
57
LanMountainDesktop.Tests/StartupVisualPreferencesTests.cs
Normal file
57
LanMountainDesktop.Tests/StartupVisualPreferencesTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class StartupVisualPreferencesTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromFlags_WhenSlideEnabled_DisablesFadeAndUsesSlideMode()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.FromFlags(
|
||||
enableFadeTransition: true,
|
||||
enableSlideTransition: true);
|
||||
|
||||
Assert.False(preferences.EnableFadeTransition);
|
||||
Assert.True(preferences.EnableSlideTransition);
|
||||
Assert.Equal(StartupVisualMode.SlideSplash, preferences.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlags_WhenFadeDisabledAndSlideDisabled_UsesStaticSplashMode()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.FromFlags(
|
||||
enableFadeTransition: false,
|
||||
enableSlideTransition: false);
|
||||
|
||||
Assert.False(preferences.EnableFadeTransition);
|
||||
Assert.False(preferences.EnableSlideTransition);
|
||||
Assert.Equal(StartupVisualMode.StaticSplash, preferences.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WhenFadeSettingMissing_DefaultsToFadeEnabled()
|
||||
{
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.StartupVisualTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
var settingsPath = Path.Combine(tempDirectory, "settings.json");
|
||||
File.WriteAllText(settingsPath, """
|
||||
{
|
||||
"enableSlideTransition": false
|
||||
}
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.Resolve(settingsPath);
|
||||
|
||||
Assert.True(preferences.EnableFadeTransition);
|
||||
Assert.False(preferences.EnableSlideTransition);
|
||||
Assert.Equal(StartupVisualMode.Fade, preferences.Mode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 999 KiB |
BIN
LanMountainDesktop/Assets/about_banner_dark.png
Normal file
BIN
LanMountainDesktop/Assets/about_banner_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 682 KiB |
BIN
LanMountainDesktop/Assets/about_banner_light.png
Normal file
BIN
LanMountainDesktop/Assets/about_banner_light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -4,7 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
@@ -90,8 +90,8 @@
|
||||
<AppVersion>$(Version)</AppVersion>
|
||||
<AppCodename>Administrate</AppCodename>
|
||||
</PropertyGroup>
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
</Target>
|
||||
|
||||
<!-- 发布时也生成版本信息文件 -->
|
||||
@@ -101,7 +101,7 @@
|
||||
<AppVersion>$(Version)</AppVersion>
|
||||
<AppCodename>Administrate</AppCodename>
|
||||
</PropertyGroup>
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -349,6 +349,11 @@
|
||||
"settings.appearance.title": "Appearance",
|
||||
"settings.appearance.description": "Adjust theme source, system material, and window chrome.",
|
||||
"settings.appearance.theme_header": "Theme",
|
||||
"settings.appearance.theme_mode_label": "Theme mode",
|
||||
"settings.appearance.theme_mode_desc": "Choose light, dark, or follow system theme.",
|
||||
"settings.appearance.theme_mode.light": "Light",
|
||||
"settings.appearance.theme_mode.dark": "Dark",
|
||||
"settings.appearance.theme_mode.follow_system": "Follow system",
|
||||
"settings.color.enable_night_mode_toggle": "Enable night mode",
|
||||
"settings.color.use_system_chrome_toggle": "Use system window chrome",
|
||||
"settings.color.theme_color_label": "Theme accent color",
|
||||
|
||||
@@ -292,6 +292,11 @@
|
||||
"settings.appearance.title": "外観",
|
||||
"settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。",
|
||||
"settings.appearance.theme_header": "テーマ",
|
||||
"settings.appearance.theme_mode_label": "テーマモード",
|
||||
"settings.appearance.theme_mode_desc": "ライト、ダーク、またはシステムに従うを選択してください。",
|
||||
"settings.appearance.theme_mode.light": "ライト",
|
||||
"settings.appearance.theme_mode.dark": "ダーク",
|
||||
"settings.appearance.theme_mode.follow_system": "システムに従う",
|
||||
"settings.color.enable_night_mode_toggle": "夜モードを有効にする",
|
||||
"settings.color.use_system_chrome_toggle": "システムのウィンドウクロームを使用",
|
||||
"settings.color.theme_color_label": "テーマのアクセントカラー",
|
||||
|
||||
@@ -338,6 +338,11 @@
|
||||
"settings.appearance.title": "외관",
|
||||
"settings.appearance.description": "테마 소스, 시스템 소재 및 창 외관을 조정합니다.",
|
||||
"settings.appearance.theme_header": "테마",
|
||||
"settings.appearance.theme_mode_label": "테마 모드",
|
||||
"settings.appearance.theme_mode_desc": "라이트, 다크 또는 시스템 설정 따르기를 선택하세요.",
|
||||
"settings.appearance.theme_mode.light": "라이트",
|
||||
"settings.appearance.theme_mode.dark": "다크",
|
||||
"settings.appearance.theme_mode.follow_system": "시스템 설정 따르기",
|
||||
"settings.color.enable_night_mode_toggle": "야간 모드 활성화",
|
||||
"settings.color.use_system_chrome_toggle": "시스템 창 제목 표시줄 사용",
|
||||
"settings.color.theme_color_label": "테마 강조 색상",
|
||||
|
||||
@@ -344,6 +344,11 @@
|
||||
"settings.appearance.title": "外观",
|
||||
"settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
|
||||
"settings.appearance.theme_header": "主题",
|
||||
"settings.appearance.theme_mode_label": "主题模式",
|
||||
"settings.appearance.theme_mode_desc": "选择日间、夜间或跟随系统主题。",
|
||||
"settings.appearance.theme_mode.light": "日间",
|
||||
"settings.appearance.theme_mode.dark": "夜间",
|
||||
"settings.appearance.theme_mode.follow_system": "跟随系统",
|
||||
"settings.color.enable_night_mode_toggle": "启用夜间模式",
|
||||
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
|
||||
"settings.color.theme_color_label": "主题强调色",
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string? SelectedWallpaperSeed { get; set; }
|
||||
|
||||
public string ThemeMode { get; set; } = "light";
|
||||
|
||||
public string? WallpaperPath { get; set; }
|
||||
|
||||
public string WallpaperType { get; set; } = "Image";
|
||||
@@ -152,8 +154,12 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool EnableThreeFingerSwipe { get; set; } = false;
|
||||
|
||||
public bool EnableFadeTransition { get; set; } = true;
|
||||
|
||||
public bool EnableSlideTransition { get; set; } = false;
|
||||
|
||||
public bool ShowInTaskbar { get; set; } = false;
|
||||
|
||||
public bool EnableFusedDesktop { get; set; } = false;
|
||||
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
@@ -22,9 +22,10 @@ public sealed class Program
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
AppDataPathProvider.Initialize(args);
|
||||
DevPluginOptions.Parse(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
if (!singleInstance.IsPrimaryInstance)
|
||||
@@ -77,6 +78,16 @@ public sealed class Program
|
||||
StartupRenderMode = renderMode;
|
||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||
App.CurrentSingleInstanceService = singleInstance;
|
||||
singleInstance.StartActivationListener(() =>
|
||||
{
|
||||
if (Avalonia.Application.Current is App app)
|
||||
{
|
||||
app.ActivateMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready.");
|
||||
});
|
||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("Startup", "Application exited normally.");
|
||||
}
|
||||
|
||||
66
LanMountainDesktop/Services/AppDataPathProvider.cs
Normal file
66
LanMountainDesktop/Services/AppDataPathProvider.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppDataPathProvider
|
||||
{
|
||||
private static string? _overriddenDataRoot;
|
||||
|
||||
public static void Initialize(string[] args)
|
||||
{
|
||||
var dataRoot = ResolveDataRootFromArgs(args);
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
_overriddenDataRoot = Path.GetFullPath(dataRoot);
|
||||
AppLogger.Info("AppDataPath", $"Data root overridden by launcher: '{_overriddenDataRoot}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var envDataRoot = Environment.GetEnvironmentVariable("LMD_DATA_ROOT");
|
||||
if (!string.IsNullOrWhiteSpace(envDataRoot))
|
||||
{
|
||||
_overriddenDataRoot = Path.GetFullPath(envDataRoot);
|
||||
AppLogger.Info("AppDataPath", $"Data root overridden by environment variable: '{_overriddenDataRoot}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetDataRoot()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_overriddenDataRoot))
|
||||
{
|
||||
return _overriddenDataRoot;
|
||||
}
|
||||
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
}
|
||||
|
||||
public static string GetSettingsDirectory()
|
||||
{
|
||||
return GetDataRoot();
|
||||
}
|
||||
|
||||
public static string GetPluginMarketDirectory()
|
||||
{
|
||||
return Path.Combine(GetDataRoot(), "PluginMarket");
|
||||
}
|
||||
|
||||
public static string GetWallpapersDirectory()
|
||||
{
|
||||
return Path.Combine(GetDataRoot(), "Wallpapers");
|
||||
}
|
||||
|
||||
private static string? ResolveDataRootFromArgs(string[] args)
|
||||
{
|
||||
const string prefix = "--data-root=";
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return arg[prefix.Length..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,7 @@ public sealed class AppDatabaseService
|
||||
|
||||
public AppDatabaseService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dataDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
var dataDirectory = AppDataPathProvider.GetDataRoot();
|
||||
_databasePath = Path.Combine(dataDirectory, "app.db");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
|
||||
|
||||
public static bool TryRestartApplication()
|
||||
{
|
||||
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
@@ -42,19 +39,34 @@ public static class AppRestartService
|
||||
public static ProcessStartInfo? CreateRestartStartInfo(
|
||||
string[]? commandLineArgs = null,
|
||||
string? processPath = null,
|
||||
string? entryAssemblyLocation = null)
|
||||
string? entryAssemblyLocation = null,
|
||||
RestartPresentationMode? restartPresentationMode = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingPath(
|
||||
var resolvedProcessPath = NormalizeExistingFile(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingFile(
|
||||
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
|
||||
var normalizedRestartPresentation = restartPresentationMode
|
||||
?? LauncherRuntimeMetadata.GetRestartPresentationMode(args)
|
||||
?? RestartPresentationMode.Foreground;
|
||||
|
||||
var launcherStartInfo = TryCreateLauncherStartInfo(
|
||||
args,
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
normalizedRestartPresentation);
|
||||
if (launcherStartInfo is not null)
|
||||
{
|
||||
return launcherStartInfo;
|
||||
}
|
||||
|
||||
if (IsDotnetHost(resolvedProcessPath))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
resolvedProcessPath!,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
|
||||
@@ -62,7 +74,8 @@ public static class AppRestartService
|
||||
return CreateExecutableStartInfo(
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
|
||||
@@ -71,7 +84,8 @@ public static class AppRestartService
|
||||
return CreateDotnetStartInfo(
|
||||
"dotnet",
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -80,22 +94,20 @@ public static class AppRestartService
|
||||
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLineArgs);
|
||||
return LauncherRuntimeMetadata.GetRestartParentProcessId(commandLineArgs);
|
||||
}
|
||||
|
||||
foreach (var argument in commandLineArgs)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(argument, out var processId))
|
||||
{
|
||||
return processId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
public static RestartPresentationMode? TryGetRestartPresentationMode(IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLineArgs);
|
||||
return LauncherRuntimeMetadata.GetRestartPresentationMode(commandLineArgs);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateExecutableStartInfo(
|
||||
string executablePath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -104,18 +116,17 @@ public static class AppRestartService
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
|
||||
var args = new System.Text.StringBuilder();
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
var arguments = new StringBuilder();
|
||||
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
|
||||
startInfo.Arguments = arguments.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo? CreateDotnetStartInfo(
|
||||
string dotnetHostPath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
|
||||
{
|
||||
@@ -129,51 +140,182 @@ public static class AppRestartService
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
// UseShellExecute=true 时使用 Arguments 字符串
|
||||
var args = new System.Text.StringBuilder();
|
||||
args.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
var arguments = new StringBuilder();
|
||||
arguments.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
|
||||
startInfo.Arguments = arguments.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
|
||||
private static ProcessStartInfo? TryCreateLauncherStartInfo(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
var launcherPath = ResolveLauncherPath(commandLineArgs, processPath, entryAssemblyPath);
|
||||
if (string.IsNullOrWhiteSpace(launcherPath))
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
return null;
|
||||
}
|
||||
|
||||
var arguments = new StringBuilder();
|
||||
AppendFilteredArguments(arguments, commandLineArgs);
|
||||
AppendRestartArguments(arguments, restartPresentationMode);
|
||||
|
||||
return new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = arguments.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveLauncherPath(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath)
|
||||
{
|
||||
var launcherFileName = OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.Launcher.exe"
|
||||
: "LanMountainDesktop.Launcher";
|
||||
|
||||
foreach (var packageRootCandidate in GetPackageRootCandidates(commandLineArgs, processPath, entryAssemblyPath))
|
||||
{
|
||||
var normalizedRoot = NormalizeExistingDirectory(packageRootCandidate);
|
||||
if (string.IsNullOrWhiteSpace(normalizedRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(commandLineArgs[i]);
|
||||
var directCandidate = Path.Combine(normalizedRoot, launcherFileName);
|
||||
if (File.Exists(directCandidate))
|
||||
{
|
||||
return directCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
private static IEnumerable<string?> GetPackageRootCandidates(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
yield return LauncherRuntimeMetadata.GetPackageRoot(commandLineArgs);
|
||||
|
||||
foreach (var path in new[] { entryAssemblyPath, processPath, AppContext.BaseDirectory })
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
var directory = GetDirectoryFromPath(path);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append(QuoteArgument(commandLineArgs[i]));
|
||||
yield return directory;
|
||||
yield return Path.GetDirectoryName(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
|
||||
private static string? GetDirectoryFromPath(string? path)
|
||||
{
|
||||
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (Directory.Exists(fullPath))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
return File.Exists(fullPath)
|
||||
? Path.GetDirectoryName(fullPath)
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
|
||||
private static void AppendForwardedArguments(
|
||||
StringBuilder builder,
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
AppendFilteredArguments(builder, commandLineArgs);
|
||||
AppendRestartArguments(builder, restartPresentationMode);
|
||||
}
|
||||
|
||||
private static void AppendFilteredArguments(StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var index = 1; index < commandLineArgs.Count; index++)
|
||||
{
|
||||
if (ShouldSkipArgument(commandLineArgs, ref index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append(QuoteArgument(commandLineArgs[index]));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldSkipArgument(IReadOnlyList<string> commandLineArgs, ref int index)
|
||||
{
|
||||
var argument = commandLineArgs[index];
|
||||
if (!argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = argument[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
key = key[..equalsIndex];
|
||||
}
|
||||
|
||||
var shouldSkip = string.Equals(key, LauncherIpcConstants.LaunchSourceOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.RestartParentPidOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.RestartPresentationOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.LauncherPidEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.PackageRootEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.VersionEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.CodenameEnvVar, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (shouldSkip &&
|
||||
equalsIndex < 0 &&
|
||||
index + 1 < commandLineArgs.Count &&
|
||||
!commandLineArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
return shouldSkip;
|
||||
}
|
||||
|
||||
private static void AppendRestartArguments(StringBuilder builder, RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append($"--{LauncherIpcConstants.LaunchSourceOptionName}=restart");
|
||||
builder.Append($" --{LauncherIpcConstants.RestartParentPidOptionName}={Environment.ProcessId}");
|
||||
builder.Append(
|
||||
$" --{LauncherIpcConstants.RestartPresentationOptionName}={LauncherRuntimeMetadata.FormatRestartPresentation(restartPresentationMode)}");
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
@@ -188,7 +330,7 @@ public static class AppRestartService
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var builder = new StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
@@ -206,21 +348,7 @@ public static class AppRestartService
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
|
||||
{
|
||||
processId = 0;
|
||||
if (string.IsNullOrWhiteSpace(argument) ||
|
||||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(
|
||||
argument[RestartParentPidArgumentPrefix.Length..],
|
||||
out processId) && processId > 0;
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingPath(string? path)
|
||||
private static string? NormalizeExistingFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
@@ -238,6 +366,24 @@ public static class AppRestartService
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return Directory.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDotnetHost(string? processPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(processPath))
|
||||
|
||||
@@ -27,8 +27,7 @@ public sealed class AppSettingsService
|
||||
|
||||
public AppSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
var settingsDirectory = AppDataPathProvider.GetSettingsDirectory();
|
||||
_settingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
|
||||
310
LanMountainDesktop/Services/DesktopTrayService.cs
Normal file
310
LanMountainDesktop/Services/DesktopTrayService.cs
Normal file
@@ -0,0 +1,310 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal enum TrayAvailabilityState
|
||||
{
|
||||
Unavailable = 0,
|
||||
Initializing = 1,
|
||||
Ready = 2,
|
||||
Recovering = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
internal sealed class DesktopTrayService : IDisposable
|
||||
{
|
||||
private readonly Application _application;
|
||||
private readonly IAppLogoService _appLogoService;
|
||||
private readonly Func<string, string, string> _localize;
|
||||
private readonly Func<bool> _shouldShowComponentLibraryMenuItem;
|
||||
private readonly EventHandler _onShowDesktop;
|
||||
private readonly EventHandler _onSettings;
|
||||
private readonly EventHandler _onComponentLibrary;
|
||||
private readonly EventHandler _onRestart;
|
||||
private readonly EventHandler _onExit;
|
||||
private readonly DispatcherTimer _watchdogTimer;
|
||||
|
||||
private TrayIcon? _trayIcon;
|
||||
private NativeMenuItem? _showDesktopMenuItem;
|
||||
private NativeMenuItem? _settingsMenuItem;
|
||||
private NativeMenuItem? _componentLibraryMenuItem;
|
||||
private NativeMenuItem? _restartMenuItem;
|
||||
private NativeMenuItem? _exitMenuItem;
|
||||
private int _consecutiveRecoveryFailures;
|
||||
private bool _disposed;
|
||||
|
||||
public DesktopTrayService(
|
||||
Application application,
|
||||
IAppLogoService appLogoService,
|
||||
Func<string, string, string> localize,
|
||||
Func<bool> shouldShowComponentLibraryMenuItem,
|
||||
EventHandler onShowDesktop,
|
||||
EventHandler onSettings,
|
||||
EventHandler onComponentLibrary,
|
||||
EventHandler onRestart,
|
||||
EventHandler onExit)
|
||||
{
|
||||
_application = application ?? throw new ArgumentNullException(nameof(application));
|
||||
_appLogoService = appLogoService ?? throw new ArgumentNullException(nameof(appLogoService));
|
||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||
_shouldShowComponentLibraryMenuItem = shouldShowComponentLibraryMenuItem ?? throw new ArgumentNullException(nameof(shouldShowComponentLibraryMenuItem));
|
||||
_onShowDesktop = onShowDesktop ?? throw new ArgumentNullException(nameof(onShowDesktop));
|
||||
_onSettings = onSettings ?? throw new ArgumentNullException(nameof(onSettings));
|
||||
_onComponentLibrary = onComponentLibrary ?? throw new ArgumentNullException(nameof(onComponentLibrary));
|
||||
_onRestart = onRestart ?? throw new ArgumentNullException(nameof(onRestart));
|
||||
_onExit = onExit ?? throw new ArgumentNullException(nameof(onExit));
|
||||
|
||||
_watchdogTimer = new DispatcherTimer(TimeSpan.FromSeconds(5), DispatcherPriority.Background, OnWatchdogTick);
|
||||
}
|
||||
|
||||
public TrayAvailabilityState State { get; private set; } = TrayAvailabilityState.Unavailable;
|
||||
|
||||
public bool IsReady => State == TrayAvailabilityState.Ready;
|
||||
|
||||
public bool HasIcon => _trayIcon?.Icon is not null;
|
||||
|
||||
public bool HasMenu => _trayIcon?.Menu is not null;
|
||||
|
||||
public bool IsVisible => _trayIcon?.IsVisible == true;
|
||||
|
||||
public int ConsecutiveRecoveryFailures => _consecutiveRecoveryFailures;
|
||||
|
||||
public event Action<TrayAvailabilityState>? StateChanged;
|
||||
|
||||
public bool EnsureReady(string reason)
|
||||
{
|
||||
if (HasHealthyTray())
|
||||
{
|
||||
_consecutiveRecoveryFailures = 0;
|
||||
SetState(TrayAvailabilityState.Ready, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryCreateOrRefreshTray(reason, isRecoveryAttempt: State != TrayAvailabilityState.Unavailable);
|
||||
}
|
||||
|
||||
public void Refresh(string reason)
|
||||
{
|
||||
if (!EnsureReady(reason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTrayContent();
|
||||
}
|
||||
|
||||
public void StartWatchdog()
|
||||
{
|
||||
if (!_watchdogTimer.IsEnabled)
|
||||
{
|
||||
_watchdogTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void StopWatchdog()
|
||||
{
|
||||
if (_watchdogTimer.IsEnabled)
|
||||
{
|
||||
_watchdogTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
StopWatchdog();
|
||||
|
||||
try
|
||||
{
|
||||
if (_trayIcon is not null)
|
||||
{
|
||||
_trayIcon.IsVisible = false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TrayIcon.SetIcons(_application, []);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_trayIcon is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_trayIcon = null;
|
||||
|
||||
SetState(TrayAvailabilityState.Unavailable, "Dispose");
|
||||
}
|
||||
|
||||
private void OnWatchdogTick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (_disposed || State == TrayAvailabilityState.Unavailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasHealthyTray())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryCreateOrRefreshTray("Watchdog", isRecoveryAttempt: true);
|
||||
}
|
||||
|
||||
private bool TryCreateOrRefreshTray(string reason, bool isRecoveryAttempt)
|
||||
{
|
||||
try
|
||||
{
|
||||
SetState(
|
||||
isRecoveryAttempt ? TrayAvailabilityState.Recovering : TrayAvailabilityState.Initializing,
|
||||
reason);
|
||||
|
||||
EnsureTrayObjects();
|
||||
ApplyTrayContent();
|
||||
TrayIcon.SetIcons(_application, [_trayIcon!]);
|
||||
|
||||
if (!HasHealthyTray())
|
||||
{
|
||||
throw new InvalidOperationException("Tray icon did not reach a healthy state after initialization.");
|
||||
}
|
||||
|
||||
_consecutiveRecoveryFailures = 0;
|
||||
SetState(TrayAvailabilityState.Ready, reason);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_consecutiveRecoveryFailures++;
|
||||
SetState(TrayAvailabilityState.Failed, $"{reason}:{ex.GetType().Name}");
|
||||
AppLogger.Warn("TrayIcon", $"Tray initialization/recovery failed. Reason='{reason}'. Attempt={_consecutiveRecoveryFailures}.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTrayObjects()
|
||||
{
|
||||
_showDesktopMenuItem ??= CreateMenuItem(_onShowDesktop);
|
||||
_settingsMenuItem ??= CreateMenuItem(_onSettings);
|
||||
_componentLibraryMenuItem ??= CreateMenuItem(_onComponentLibrary);
|
||||
_restartMenuItem ??= CreateMenuItem(_onRestart);
|
||||
_exitMenuItem ??= CreateMenuItem(_onExit);
|
||||
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
var trayMenu = new NativeMenu();
|
||||
trayMenu.Items.Add(_showDesktopMenuItem);
|
||||
trayMenu.Items.Add(_settingsMenuItem);
|
||||
trayMenu.Items.Add(_componentLibraryMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_restartMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_exitMenuItem);
|
||||
|
||||
_trayIcon = new TrayIcon
|
||||
{
|
||||
Menu = trayMenu
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyTrayContent()
|
||||
{
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_trayIcon.Icon = _appLogoService.CreateTrayIcon();
|
||||
_trayIcon.IsVisible = true;
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
_trayIcon.ToolTipText = _localize("tray.tooltip", "LanMountainDesktop");
|
||||
}
|
||||
|
||||
if (_showDesktopMenuItem is not null)
|
||||
{
|
||||
_showDesktopMenuItem.Header = _localize("tray.menu.show_desktop", "Open Desktop");
|
||||
}
|
||||
|
||||
if (_settingsMenuItem is not null)
|
||||
{
|
||||
_settingsMenuItem.Header = _localize("tray.menu.settings", "Settings");
|
||||
}
|
||||
|
||||
if (_componentLibraryMenuItem is not null)
|
||||
{
|
||||
_componentLibraryMenuItem.IsVisible = _shouldShowComponentLibraryMenuItem();
|
||||
if (_componentLibraryMenuItem.IsVisible)
|
||||
{
|
||||
_componentLibraryMenuItem.Header = _localize("tray.menu.component_library", "Component Library");
|
||||
}
|
||||
}
|
||||
|
||||
if (_restartMenuItem is not null)
|
||||
{
|
||||
_restartMenuItem.Header = _localize("tray.menu.restart", "Restart App");
|
||||
}
|
||||
|
||||
if (_exitMenuItem is not null)
|
||||
{
|
||||
_exitMenuItem.Header = _localize("tray.menu.exit", "Exit App");
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasHealthyTray()
|
||||
{
|
||||
return _trayIcon is not null &&
|
||||
_trayIcon.Menu is not null &&
|
||||
_trayIcon.Icon is not null &&
|
||||
_trayIcon.IsVisible &&
|
||||
_showDesktopMenuItem is not null &&
|
||||
_settingsMenuItem is not null &&
|
||||
_componentLibraryMenuItem is not null &&
|
||||
_restartMenuItem is not null &&
|
||||
_exitMenuItem is not null;
|
||||
}
|
||||
|
||||
private void SetState(TrayAvailabilityState state, string reason)
|
||||
{
|
||||
if (State == state)
|
||||
{
|
||||
if (state == TrayAvailabilityState.Failed)
|
||||
{
|
||||
StateChanged?.Invoke(state);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = State;
|
||||
State = state;
|
||||
AppLogger.Info("TrayIcon", $"Tray availability changed. Previous='{previous}'; Current='{state}'; Reason='{reason}'.");
|
||||
StateChanged?.Invoke(state);
|
||||
}
|
||||
|
||||
private static NativeMenuItem CreateMenuItem(EventHandler clickHandler)
|
||||
{
|
||||
var item = new NativeMenuItem();
|
||||
item.Click += clickHandler;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||
|
||||
internal sealed class PublicAppInfoService : IPublicAppInfoService
|
||||
{
|
||||
private readonly string _version;
|
||||
private readonly string _codename;
|
||||
private readonly DateTimeOffset _startedAt;
|
||||
|
||||
public PublicAppInfoService(string version, string codename, DateTimeOffset startedAt)
|
||||
public PublicAppInfoService(DateTimeOffset startedAt)
|
||||
{
|
||||
_version = version;
|
||||
_codename = codename;
|
||||
_startedAt = startedAt;
|
||||
}
|
||||
|
||||
public PublicAppInfoSnapshot GetAppInfo()
|
||||
{
|
||||
var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
|
||||
return new PublicAppInfoSnapshot(
|
||||
"LanMountainDesktop",
|
||||
_version,
|
||||
_codename,
|
||||
versionInfo.Version,
|
||||
versionInfo.Codename,
|
||||
IpcConstants.DefaultPipeName,
|
||||
Environment.ProcessId,
|
||||
_startedAt);
|
||||
|
||||
@@ -7,6 +7,15 @@ namespace LanMountainDesktop.Services.ExternalIpc;
|
||||
|
||||
internal sealed class PublicShellControlService : IPublicShellControlService
|
||||
{
|
||||
public Task<PublicShellStatus> GetShellStatusAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.GetPublicShellStatus()
|
||||
?? CreateUnavailableStatus();
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<bool> ActivateMainWindowAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
@@ -15,6 +24,37 @@ internal sealed class PublicShellControlService : IPublicShellControlService
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<PublicShellActivationResult> ActivateMainWindowWithStatusAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.TryActivateMainWindowWithStatusFromExternalIpc("PublicIpc")
|
||||
?? new PublicShellActivationResult(
|
||||
false,
|
||||
"app_unavailable",
|
||||
"Application instance is not available.",
|
||||
CreateUnavailableStatus());
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<PublicTrayStatus> EnsureTrayReadyAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.EnsureTrayReadyFromExternalIpc("PublicIpc")
|
||||
?? new PublicTrayStatus("Unavailable", false, false, false, false, 0);
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.EnsureTaskbarEntryFromExternalIpc("PublicIpc")
|
||||
?? new PublicTaskbarStatus(false, false, false, false, false, false);
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<bool> OpenSettingsAsync(string? pageTag = null)
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
@@ -44,4 +84,20 @@ internal sealed class PublicShellControlService : IPublicShellControlService
|
||||
Source: "PublicIpc",
|
||||
Reason: "External IPC requested exit.")) == true);
|
||||
}
|
||||
|
||||
private static PublicShellStatus CreateUnavailableStatus()
|
||||
{
|
||||
return new PublicShellStatus(
|
||||
Environment.ProcessId,
|
||||
DateTimeOffset.UtcNow,
|
||||
"unknown",
|
||||
"Unavailable",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
new PublicTrayStatus("Unavailable", false, false, false, false, 0),
|
||||
new PublicTaskbarStatus(false, false, false, false, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
@@ -22,23 +23,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
|
||||
|
||||
app = Application.Current as App;
|
||||
if (app?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
if (app is null || app.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
|
||||
{
|
||||
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
|
||||
return false;
|
||||
}
|
||||
|
||||
app.PrepareForShutdown(isRestart: false, request?.Source ?? "Unknown");
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
return true;
|
||||
return app.TrySubmitShutdown(HostShutdownMode.Exit, request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -54,6 +45,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
try
|
||||
{
|
||||
app = Application.Current as App;
|
||||
if (app?.IsShutdownInProgress == true)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"HostLifecycle",
|
||||
$"Restart request ignored because shutdown is already in progress. Source='{request?.Source ?? "Unknown"}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasPendingPluginUpgrades())
|
||||
{
|
||||
@@ -105,7 +103,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
|
||||
var launchArgs = startInfo?.Arguments ?? "";
|
||||
|
||||
@@ -120,16 +120,14 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
|
||||
|
||||
Process.Start(helperStartInfo);
|
||||
|
||||
var app = Application.Current as App;
|
||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
||||
|
||||
return TryExit(request);
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
|
||||
}
|
||||
|
||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
if (startInfo is null)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
@@ -139,9 +137,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
var app = Application.Current as App;
|
||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
||||
var exitRequest = request is null
|
||||
var shutdownRequest = request is null
|
||||
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
|
||||
: request with
|
||||
{
|
||||
@@ -150,7 +146,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
: request.Reason
|
||||
};
|
||||
|
||||
return TryExit(exitRequest);
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
|
||||
}
|
||||
|
||||
private static string ResolveUpgradeHelperPath()
|
||||
|
||||
65
LanMountainDesktop/Services/HostShutdownGate.cs
Normal file
65
LanMountainDesktop/Services/HostShutdownGate.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal enum HostShutdownMode
|
||||
{
|
||||
Exit = 0,
|
||||
Restart = 1
|
||||
}
|
||||
|
||||
internal readonly record struct HostShutdownSubmission(
|
||||
bool Accepted,
|
||||
bool IsFirstSubmission,
|
||||
HostShutdownMode EffectiveMode,
|
||||
HostShutdownMode RequestedMode);
|
||||
|
||||
internal sealed class HostShutdownGate
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private bool _submitted;
|
||||
private HostShutdownMode _mode;
|
||||
|
||||
public bool IsShutdownRequested
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _submitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HostShutdownMode? EffectiveMode
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _submitted ? _mode : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HostShutdownSubmission Submit(HostShutdownMode requestedMode)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_submitted)
|
||||
{
|
||||
_submitted = true;
|
||||
_mode = requestedMode;
|
||||
return new HostShutdownSubmission(
|
||||
Accepted: true,
|
||||
IsFirstSubmission: true,
|
||||
EffectiveMode: requestedMode,
|
||||
RequestedMode: requestedMode);
|
||||
}
|
||||
|
||||
return new HostShutdownSubmission(
|
||||
Accepted: _mode == requestedMode,
|
||||
IsFirstSubmission: false,
|
||||
EffectiveMode: _mode,
|
||||
RequestedMode: requestedMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,7 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
namespace LanMountainDesktop.Services.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
|
||||
/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
|
||||
/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
|
||||
/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。
|
||||
/// </summary>
|
||||
public class LauncherIpcClient : IDisposable
|
||||
{
|
||||
@@ -18,23 +16,14 @@ public class LauncherIpcClient : IDisposable
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
private NamedPipeClientStream? _pipeClient;
|
||||
private bool _isConnected;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否已连接到 Launcher
|
||||
/// </summary>
|
||||
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
|
||||
|
||||
/// <summary>
|
||||
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||
/// </summary>
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 连接到 Launcher 的 IPC 服务端
|
||||
/// </summary>
|
||||
public async Task<bool> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -50,7 +39,6 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Launcher 可能没有启动 IPC 服务端,这是正常的
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -60,24 +48,20 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告启动进度(在同一连接上可多次调用)
|
||||
/// </summary>
|
||||
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||
{
|
||||
if (!_isConnected || _pipeClient?.IsConnected != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(message, StartupProgressJsonOptions);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// 长度前缀协议:[4字节长度][消息正文]
|
||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||
Debug.Assert(lengthPrefix.Length == LengthPrefixSize);
|
||||
|
||||
// 加锁保证单条消息的长度前缀和正文原子写入
|
||||
lock (_writeLock)
|
||||
{
|
||||
_pipeClient.Write(lengthPrefix, 0, LengthPrefixSize);
|
||||
@@ -85,12 +69,10 @@ public class LauncherIpcClient : IDisposable
|
||||
_pipeClient.Flush();
|
||||
}
|
||||
|
||||
// 将同步写入包装为已完成的 Task
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 管道断开
|
||||
_isConnected = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -100,30 +82,9 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否从 Launcher 启动
|
||||
/// 优先检查环境变量,回退到命令行参数(UseShellExecute=true 时环境变量仍可继承,
|
||||
/// 命令行参数作为备选确保兼容性)
|
||||
/// </summary>
|
||||
public static bool IsLaunchedByLauncher()
|
||||
{
|
||||
// 优先检查环境变量
|
||||
if (!string.IsNullOrEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>)
|
||||
foreach (var arg in Environment.GetCommandLineArgs())
|
||||
{
|
||||
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return LauncherRuntimeMetadata.GetLauncherProcessId(Environment.GetCommandLineArgs()) is not null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -30,8 +30,7 @@ public sealed class LauncherSettingsService
|
||||
|
||||
public LauncherSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
var settingsDirectory = AppDataPathProvider.GetSettingsDirectory();
|
||||
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ internal sealed class NotificationWindowManager
|
||||
|
||||
var screen = GetPrimaryScreen();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scale = 1d;
|
||||
var scale = screen?.Scaling ?? 1d;
|
||||
|
||||
for (var i = 0; i < windows.Count; i++)
|
||||
{
|
||||
@@ -432,12 +432,19 @@ internal sealed class NotificationWindowManager
|
||||
int stackIndex)
|
||||
{
|
||||
window.Measure(Size.Infinity);
|
||||
var windowWidth = window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
|
||||
var windowHeight = window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
|
||||
var windowWidthDip = window.Bounds.Width > 0
|
||||
? window.Bounds.Width
|
||||
: window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
|
||||
var windowHeightDip = window.Bounds.Height > 0
|
||||
? window.Bounds.Height
|
||||
: window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
|
||||
|
||||
var windowWidth = (int)Math.Round(windowWidthDip * scale);
|
||||
var windowHeight = (int)Math.Round(windowHeightDip * scale);
|
||||
|
||||
var margin = (int)Math.Round(Margin * scale);
|
||||
var spacing = (int)Math.Round(Spacing * scale);
|
||||
var stackedOffset = stackIndex * ((int)Math.Round(windowHeight) + spacing);
|
||||
var stackedOffset = stackIndex * (windowHeight + spacing);
|
||||
|
||||
return position switch
|
||||
{
|
||||
@@ -446,31 +453,31 @@ internal sealed class NotificationWindowManager
|
||||
workingArea.Y + margin + stackedOffset),
|
||||
|
||||
NotificationPosition.TopRight => new PixelPoint(
|
||||
workingArea.Right - (int)Math.Round(windowWidth) - margin,
|
||||
workingArea.Right - windowWidth - margin,
|
||||
workingArea.Y + margin + stackedOffset),
|
||||
|
||||
NotificationPosition.TopCenter => new PixelPoint(
|
||||
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
|
||||
workingArea.X + (workingArea.Width - windowWidth) / 2,
|
||||
workingArea.Y + margin + stackedOffset),
|
||||
|
||||
NotificationPosition.BottomLeft => new PixelPoint(
|
||||
workingArea.X + margin,
|
||||
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
|
||||
workingArea.Bottom - windowHeight - margin - stackedOffset),
|
||||
|
||||
NotificationPosition.BottomRight => new PixelPoint(
|
||||
workingArea.Right - (int)Math.Round(windowWidth) - margin,
|
||||
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
|
||||
workingArea.Right - windowWidth - margin,
|
||||
workingArea.Bottom - windowHeight - margin - stackedOffset),
|
||||
|
||||
NotificationPosition.BottomCenter => new PixelPoint(
|
||||
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
|
||||
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
|
||||
workingArea.X + (workingArea.Width - windowWidth) / 2,
|
||||
workingArea.Bottom - windowHeight - margin - stackedOffset),
|
||||
|
||||
NotificationPosition.Center => new PixelPoint(
|
||||
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
|
||||
workingArea.Y + (workingArea.Height - (int)Math.Round(windowHeight)) / 2),
|
||||
workingArea.X + (workingArea.Width - windowWidth) / 2,
|
||||
workingArea.Y + (workingArea.Height - windowHeight) / 2),
|
||||
|
||||
_ => new PixelPoint(
|
||||
workingArea.Right - (int)Math.Round(windowWidth) - margin,
|
||||
workingArea.Right - windowWidth - margin,
|
||||
workingArea.Y + margin + stackedOffset)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,9 +76,7 @@ internal sealed class SqliteComponentDomainStorage :
|
||||
public SqliteComponentDomainStorage(string? settingsRoot = null)
|
||||
{
|
||||
_settingsRoot = string.IsNullOrWhiteSpace(settingsRoot)
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop")
|
||||
? AppDataPathProvider.GetDataRoot()
|
||||
: settingsRoot.Trim();
|
||||
_dbPath = Path.Combine(_settingsRoot, "component-state.db");
|
||||
_layoutJsonPath = Path.Combine(_settingsRoot, "desktop-layout-settings.json");
|
||||
|
||||
@@ -33,7 +33,8 @@ public sealed record ThemeAppearanceSettingsState(
|
||||
string CornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle,
|
||||
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
||||
string? SelectedWallpaperSeed = null);
|
||||
string? SelectedWallpaperSeed = null,
|
||||
string ThemeMode = ThemeAppearanceValues.ThemeModeLight);
|
||||
public sealed record StatusBarSettingsState(
|
||||
IReadOnlyList<string> TopStatusComponentIds,
|
||||
IReadOnlyList<string> PinnedTaskbarActions,
|
||||
|
||||
@@ -167,10 +167,7 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
|
||||
|
||||
public WallpaperMediaService()
|
||||
{
|
||||
var appDataRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
_wallpapersDirectory = Path.Combine(appDataRoot, "Wallpapers");
|
||||
_wallpapersDirectory = AppDataPathProvider.GetWallpapersDirectory();
|
||||
}
|
||||
|
||||
public WallpaperMediaType DetectMediaType(string? path)
|
||||
@@ -269,7 +266,21 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
cornerRadiusStyle,
|
||||
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
||||
snapshot.SelectedWallpaperSeed);
|
||||
snapshot.SelectedWallpaperSeed,
|
||||
NormalizeThemeMode(snapshot.ThemeMode));
|
||||
}
|
||||
|
||||
private static string NormalizeThemeMode(string? value)
|
||||
{
|
||||
if (string.Equals(value, ThemeAppearanceValues.ThemeModeDark, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ThemeAppearanceValues.ThemeModeDark;
|
||||
}
|
||||
if (string.Equals(value, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ThemeAppearanceValues.ThemeModeFollowSystem;
|
||||
}
|
||||
return ThemeAppearanceValues.ThemeModeLight;
|
||||
}
|
||||
|
||||
public void Save(ThemeAppearanceSettingsState state)
|
||||
@@ -326,6 +337,13 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed));
|
||||
}
|
||||
|
||||
var normalizedThemeMode = NormalizeThemeMode(state.ThemeMode);
|
||||
if (!string.Equals(snapshot.ThemeMode, normalizedThemeMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
snapshot.ThemeMode = normalizedThemeMode;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.ThemeMode));
|
||||
}
|
||||
|
||||
if (changedKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
@@ -1026,10 +1044,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
{
|
||||
_pluginRuntimeService = pluginRuntimeService;
|
||||
|
||||
var dataRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"PluginMarket");
|
||||
var dataRoot = AppDataPathProvider.GetPluginMarketDirectory();
|
||||
var cacheService = new AirAppMarketCacheService(dataRoot);
|
||||
_indexService = new AirAppMarketIndexService(cacheService);
|
||||
if (_pluginRuntimeService is not null)
|
||||
@@ -1049,10 +1064,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
return;
|
||||
}
|
||||
|
||||
var dataRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"PluginMarket");
|
||||
var dataRoot = AppDataPathProvider.GetPluginMarketDirectory();
|
||||
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
||||
}
|
||||
|
||||
@@ -1290,6 +1302,10 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string GetAppVersionText()
|
||||
{
|
||||
return LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider
|
||||
.ResolveForCurrentProcess()
|
||||
.Version;
|
||||
|
||||
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envVersion))
|
||||
@@ -1337,6 +1353,10 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string GetAppCodenameText()
|
||||
{
|
||||
return LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider
|
||||
.ResolveForCurrentProcess()
|
||||
.Codename;
|
||||
|
||||
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envCodename))
|
||||
|
||||
@@ -26,9 +26,7 @@ internal sealed class SettingsService : ISettingsService
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
var root = AppDataPathProvider.GetDataRoot();
|
||||
_pluginSettingsPath = Path.Combine(root, "plugin-settings.json");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,28 +14,10 @@ using LanMountainDesktop.Views;
|
||||
|
||||
namespace LanMountainDesktop.Services.Settings;
|
||||
|
||||
public enum SettingsWindowAnchorTarget
|
||||
{
|
||||
DesktopDockTrailingEdge = 0
|
||||
}
|
||||
|
||||
public enum SettingsWindowFallbackMode
|
||||
{
|
||||
None = 0,
|
||||
ScreenBottomRight = 1
|
||||
}
|
||||
|
||||
public readonly record struct SettingsWindowOpenRequest(
|
||||
string Source,
|
||||
Window? Owner = null,
|
||||
string? PageId = null,
|
||||
SettingsWindowAnchorTarget AnchorTarget = SettingsWindowAnchorTarget.DesktopDockTrailingEdge,
|
||||
SettingsWindowFallbackMode FallbackMode = SettingsWindowFallbackMode.ScreenBottomRight);
|
||||
|
||||
public interface ISettingsWindowAnchorProvider
|
||||
{
|
||||
bool TryGetSettingsWindowAnchorBounds(out PixelRect anchorBounds);
|
||||
}
|
||||
Window? ScreenReferenceWindow = null);
|
||||
|
||||
public interface ISettingsWindowService
|
||||
{
|
||||
@@ -46,8 +28,6 @@ public interface ISettingsWindowService
|
||||
void Open(SettingsWindowOpenRequest request);
|
||||
|
||||
void Close();
|
||||
|
||||
void Toggle(SettingsWindowOpenRequest request);
|
||||
}
|
||||
|
||||
internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
@@ -92,27 +72,25 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
|
||||
_window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
|
||||
ApplyTheme(_window);
|
||||
_window.ReloadPages(request.PageId);
|
||||
PositionWindow(_window, request);
|
||||
|
||||
var targetPageId = request.PageId ?? _window.ViewModel.CurrentPageId;
|
||||
_window.ReloadPages(targetPageId);
|
||||
|
||||
if (!_window.IsVisible)
|
||||
{
|
||||
if (request.Owner is not null && request.Owner.IsVisible)
|
||||
{
|
||||
_window.Show(request.Owner);
|
||||
}
|
||||
else
|
||||
{
|
||||
_window.Show();
|
||||
}
|
||||
|
||||
CenterWindow(_window, request);
|
||||
_window.Show();
|
||||
NotifyStateChanged();
|
||||
PositionWindowLater(_window, request);
|
||||
CenterWindowLater(_window, request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_window.WindowState == WindowState.Minimized)
|
||||
{
|
||||
_window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
_window.Activate();
|
||||
PositionWindowLater(_window, request);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
@@ -120,17 +98,6 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
_window?.Close();
|
||||
}
|
||||
|
||||
public void Toggle(SettingsWindowOpenRequest request)
|
||||
{
|
||||
if (IsOpen)
|
||||
{
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
Open(request);
|
||||
}
|
||||
|
||||
private SettingsWindow CreateWindow()
|
||||
{
|
||||
var regionState = _settingsFacade.Region.Get();
|
||||
@@ -147,7 +114,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
_hostApplicationLifecycle,
|
||||
useSystemChrome);
|
||||
ApplyTheme(window);
|
||||
window.ShowInTaskbar = false;
|
||||
window.ShowInTaskbar = true;
|
||||
window.Closed += (_, _) =>
|
||||
{
|
||||
_window = null;
|
||||
@@ -156,106 +123,87 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
return window;
|
||||
}
|
||||
|
||||
private void PositionWindowLater(SettingsWindow window, SettingsWindowOpenRequest request)
|
||||
private void CenterWindowLater(SettingsWindow window, SettingsWindowOpenRequest request)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (!window.IsVisible)
|
||||
if (!ReferenceEquals(_window, window) || !window.IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PositionWindow(window, request);
|
||||
CenterWindow(window, request);
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static void PositionWindow(SettingsWindow window, SettingsWindowOpenRequest request)
|
||||
private static void CenterWindow(SettingsWindow window, SettingsWindowOpenRequest request)
|
||||
{
|
||||
if (request.AnchorTarget == SettingsWindowAnchorTarget.DesktopDockTrailingEdge &&
|
||||
request.Owner is ISettingsWindowAnchorProvider anchorProvider &&
|
||||
anchorProvider.TryGetSettingsWindowAnchorBounds(out var anchorBounds))
|
||||
{
|
||||
PositionWindowAboveAnchor(window, anchorBounds, request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.FallbackMode == SettingsWindowFallbackMode.ScreenBottomRight)
|
||||
{
|
||||
PositionWindowNearScreenBottomRight(window, request);
|
||||
}
|
||||
var referenceWorkingArea =
|
||||
request.ScreenReferenceWindow is { IsVisible: true } screenReferenceWindow &&
|
||||
screenReferenceWindow.Screens?.ScreenFromWindow(screenReferenceWindow) is { } referenceScreen
|
||||
? referenceScreen.WorkingArea
|
||||
: (PixelRect?)null;
|
||||
var width = ResolveWindowWidth(window, request.ScreenReferenceWindow);
|
||||
var height = ResolveWindowHeight(window, request.ScreenReferenceWindow);
|
||||
var workingArea = SettingsWindowPlacementHelper.ResolveWorkingArea(
|
||||
referenceWorkingArea,
|
||||
window.Screens?.Primary?.WorkingArea,
|
||||
width,
|
||||
height);
|
||||
window.Position = SettingsWindowPlacementHelper.CalculateCenteredPosition(workingArea, width, height);
|
||||
}
|
||||
|
||||
private static void PositionWindowAboveAnchor(Window window, PixelRect anchorBounds, SettingsWindowOpenRequest request)
|
||||
private static int ResolveWindowWidth(Window window, Window? referenceWindow)
|
||||
{
|
||||
var workingArea = GetWorkingArea(window, request);
|
||||
|
||||
if (anchorBounds.Width <= 0 || anchorBounds.Height <= 0 ||
|
||||
anchorBounds.Right < workingArea.X || anchorBounds.Y > workingArea.Bottom)
|
||||
{
|
||||
PositionWindowNearScreenBottomRight(window, request);
|
||||
return;
|
||||
}
|
||||
|
||||
var scale = window.RenderScaling > 0 ? window.RenderScaling : 1d;
|
||||
var width = ResolveWindowWidth(window, scale);
|
||||
var height = ResolveWindowHeight(window, scale);
|
||||
var inset = (int)Math.Round(24 * scale);
|
||||
var gap = (int)Math.Round(16 * scale);
|
||||
|
||||
var x = anchorBounds.Right - width - inset;
|
||||
var y = anchorBounds.Y - height - gap;
|
||||
x = Math.Clamp(x, workingArea.X + inset, Math.Max(workingArea.X + inset, workingArea.Right - width - inset));
|
||||
y = Math.Clamp(y, workingArea.Y + inset, Math.Max(workingArea.Y + inset, workingArea.Bottom - height - inset));
|
||||
window.Position = new PixelPoint(x, y);
|
||||
}
|
||||
|
||||
private static void PositionWindowNearScreenBottomRight(Window window, SettingsWindowOpenRequest request)
|
||||
{
|
||||
var workingArea = GetWorkingArea(window, request);
|
||||
var scale = window.RenderScaling > 0 ? window.RenderScaling : 1d;
|
||||
var width = ResolveWindowWidth(window, scale);
|
||||
var height = ResolveWindowHeight(window, scale);
|
||||
var inset = (int)Math.Round(24 * scale);
|
||||
|
||||
var x = Math.Max(workingArea.X + inset, workingArea.Right - width - inset);
|
||||
var y = Math.Max(workingArea.Y + inset, workingArea.Bottom - height - inset);
|
||||
window.Position = new PixelPoint(x, y);
|
||||
}
|
||||
|
||||
private static PixelRect GetWorkingArea(Window window, SettingsWindowOpenRequest request)
|
||||
{
|
||||
if (request.Owner is not null && request.Owner.Screens?.ScreenFromWindow(request.Owner) is { } ownerScreen)
|
||||
{
|
||||
return ownerScreen.WorkingArea;
|
||||
}
|
||||
|
||||
if (window.Screens?.ScreenFromWindow(window) is { } windowScreen)
|
||||
{
|
||||
return windowScreen.WorkingArea;
|
||||
}
|
||||
|
||||
return window.Screens?.Primary?.WorkingArea
|
||||
?? new PixelRect(
|
||||
0,
|
||||
0,
|
||||
Math.Max(1280, ResolveWindowWidth(window, 1d) + 96),
|
||||
Math.Max(720, ResolveWindowHeight(window, 1d) + 96));
|
||||
}
|
||||
|
||||
private static int ResolveWindowWidth(Window window, double scale)
|
||||
{
|
||||
var widthDip = window.Bounds.Width > 1 ? window.Bounds.Width : Math.Max(window.Width, window.MinWidth);
|
||||
var widthDip = ResolveWindowDimensionDip(window.Bounds.Width, window.Width, window.MinWidth, 1120d);
|
||||
var scale = ResolveWindowScale(window, referenceWindow);
|
||||
return Math.Max(320, (int)Math.Round(widthDip * scale));
|
||||
}
|
||||
|
||||
private static int ResolveWindowHeight(Window window, double scale)
|
||||
private static int ResolveWindowHeight(Window window, Window? referenceWindow)
|
||||
{
|
||||
var heightDip = window.Bounds.Height > 1 ? window.Bounds.Height : Math.Max(window.Height, window.MinHeight);
|
||||
var heightDip = ResolveWindowDimensionDip(window.Bounds.Height, window.Height, window.MinHeight, 760d);
|
||||
var scale = ResolveWindowScale(window, referenceWindow);
|
||||
return Math.Max(240, (int)Math.Round(heightDip * scale));
|
||||
}
|
||||
|
||||
private static double ResolveWindowScale(Window window, Window? referenceWindow)
|
||||
{
|
||||
if (referenceWindow is not null && referenceWindow.RenderScaling > 0)
|
||||
{
|
||||
return referenceWindow.RenderScaling;
|
||||
}
|
||||
|
||||
if (window.RenderScaling > 0)
|
||||
{
|
||||
return window.RenderScaling;
|
||||
}
|
||||
|
||||
return 1d;
|
||||
}
|
||||
|
||||
private static double ResolveWindowDimensionDip(double boundsDip, double configuredDip, double minimumDip, double fallbackDip)
|
||||
{
|
||||
if (boundsDip > 1)
|
||||
{
|
||||
return boundsDip;
|
||||
}
|
||||
|
||||
if (!double.IsNaN(configuredDip) && configuredDip > 1)
|
||||
{
|
||||
return configuredDip;
|
||||
}
|
||||
|
||||
if (!double.IsNaN(minimumDip) && minimumDip > 1)
|
||||
{
|
||||
return minimumDip;
|
||||
}
|
||||
|
||||
return fallbackDip;
|
||||
}
|
||||
|
||||
private void NotifyStateChanged()
|
||||
{
|
||||
StateChanged?.Invoke(this, EventArgs.Empty);
|
||||
@@ -363,3 +311,38 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class SettingsWindowPlacementHelper
|
||||
{
|
||||
internal static PixelRect ResolveWorkingArea(
|
||||
PixelRect? referenceWorkingArea,
|
||||
PixelRect? primaryWorkingArea,
|
||||
int fallbackWindowWidth,
|
||||
int fallbackWindowHeight)
|
||||
{
|
||||
if (referenceWorkingArea is { } referenceArea)
|
||||
{
|
||||
return referenceArea;
|
||||
}
|
||||
|
||||
if (primaryWorkingArea is { } primaryArea)
|
||||
{
|
||||
return primaryArea;
|
||||
}
|
||||
|
||||
return new PixelRect(
|
||||
0,
|
||||
0,
|
||||
Math.Max(1280, fallbackWindowWidth + 96),
|
||||
Math.Max(720, fallbackWindowHeight + 96));
|
||||
}
|
||||
|
||||
internal static PixelPoint CalculateCenteredPosition(PixelRect workingArea, int windowWidth, int windowHeight)
|
||||
{
|
||||
var horizontalOffset = Math.Max(0, (workingArea.Width - windowWidth) / 2);
|
||||
var verticalOffset = Math.Max(0, (workingArea.Height - windowHeight) / 2);
|
||||
return new PixelPoint(
|
||||
workingArea.X + horizontalOffset,
|
||||
workingArea.Y + verticalOffset);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user