mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
@@ -52,7 +57,7 @@ public partial class App : Application
|
||||
}
|
||||
else
|
||||
{
|
||||
var splashWindow = new SplashWindow();
|
||||
var splashWindow = CreateSplashWindow();
|
||||
splashWindow.Show();
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
@@ -68,7 +73,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 +117,28 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.Resolve();
|
||||
var window = new SplashWindow(preferences.Mode);
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||
{
|
||||
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
||||
@@ -172,53 +199,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 +542,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 +579,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,6 +21,13 @@ 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))]
|
||||
|
||||
@@ -121,6 +121,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 +147,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>
|
||||
<!-- 应用程序图标 -->
|
||||
@@ -35,6 +35,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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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-* 子目录(发布版)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
@@ -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,21 +356,21 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫æ<EFBFBD><EFBFBD>å¼€å<EFBFBD>‘路径(开å<EFBFBD>‘模å¼<EFBFBD>)
|
||||
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
|
||||
/// </summary>
|
||||
private static string? ScanDevelopmentPaths(string executable)
|
||||
{
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// ä»?Launcher 项目è¿<EFBFBD>行
|
||||
// 浠?Launcher 椤圭洰杩愯
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<EFBFBD>行
|
||||
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// dev-test 目录
|
||||
// dev-test 鐩綍
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
@@ -359,22 +386,22 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获å<EFBFBD>–å¼€å<EFBFBD>‘环境å<EFBFBD>¯èƒ½çš„主程åº<EFBFBD>è·¯å¾? /// </summary>
|
||||
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// ä»?Launcher 项目è¿<EFBFBD>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// 浠?Launcher 椤圭洰杩愯锛?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<EFBFBD>行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯锛歀anMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»?dev-test 目录è¿<EFBFBD>行
|
||||
// 浠?dev-test 鐩綍杩愯
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
@@ -409,8 +436,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 +465,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 +476,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,7 +488,7 @@ internal sealed class DeploymentLocator
|
||||
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
|
||||
}
|
||||
|
||||
// 3. ä¿<EFBFBD>留有快照的版本(用于回滚)
|
||||
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
|
||||
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||
if (Directory.Exists(snapshotDir))
|
||||
{
|
||||
@@ -485,17 +512,17 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照解æž<EFBFBD>错误
|
||||
// 蹇界暐蹇収瑙f瀽閿欒
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照目录访问错误
|
||||
// 蹇界暐蹇収鐩綍璁块棶閿欒
|
||||
}
|
||||
}
|
||||
|
||||
// 清ç<EFBFBD>†ä¸<EFBFBD>需è¦<EFBFBD>的版本
|
||||
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
|
||||
foreach (var deployment in validDeployments)
|
||||
{
|
||||
if (versionsToKeep.Contains(deployment.Path))
|
||||
@@ -509,7 +536,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略å<EFBFBD>–æ¶ˆæ ‡è®°å¤±è´¥
|
||||
// 蹇界暐鍙栨秷鏍囪澶辫触
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -524,11 +551,11 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// å¿½ç•¥æ ‡è®°å¤±è´¥
|
||||
// 蹇界暐鏍囪澶辫触
|
||||
}
|
||||
}
|
||||
|
||||
// å°<EFBFBD>è¯•åˆ é™¤
|
||||
// 灏濊瘯鍒犻櫎
|
||||
try
|
||||
{
|
||||
Directory.Delete(deployment.Path, recursive: true);
|
||||
@@ -536,7 +563,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// å¿½ç•¥åˆ é™¤å¤±è´¥(å<>¯èƒ½æ–‡ä»¶è¢«å<C2AB> ç”?,下次å<C2A1>¯åЍå†<C3A5>试
|
||||
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
|
||||
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
|
||||
}
|
||||
}
|
||||
@@ -544,12 +571,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 +608,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;
|
||||
}
|
||||
|
||||
|
||||
199
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
199
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed record HostLaunchPlan(
|
||||
string HostPath,
|
||||
string PackageRoot,
|
||||
string WorkingDirectory,
|
||||
IReadOnlyList<string> Arguments,
|
||||
IReadOnlyDictionary<string, string> EnvironmentVariables,
|
||||
AppVersionInfo VersionInfo);
|
||||
|
||||
internal static class HostLaunchPlanBuilder
|
||||
{
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
public static HostLaunchPlan Build(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
HostResolutionResult resolution)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(deploymentLocator);
|
||||
ArgumentNullException.ThrowIfNull(resolution);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
||||
{
|
||||
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
|
||||
}
|
||||
|
||||
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
|
||||
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
|
||||
var versionInfo = deploymentLocator.GetVersionInfo();
|
||||
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo);
|
||||
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
|
||||
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
|
||||
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
|
||||
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
|
||||
};
|
||||
|
||||
return new HostLaunchPlan(
|
||||
hostPath,
|
||||
packageRoot,
|
||||
Directory.Exists(packageRoot)
|
||||
? packageRoot
|
||||
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
|
||||
arguments,
|
||||
environment,
|
||||
versionInfo);
|
||||
}
|
||||
|
||||
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
|
||||
{
|
||||
return string.Join(" ", arguments.Select(QuoteArgument));
|
||||
}
|
||||
|
||||
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
|
||||
{
|
||||
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
|
||||
? AppContext.BaseDirectory
|
||||
: Path.GetFullPath(appRoot);
|
||||
|
||||
var hostDirectory = Path.GetDirectoryName(hostPath);
|
||||
if (hostDirectory is not null &&
|
||||
Directory.Exists(fullAppRoot) &&
|
||||
IsAppDeploymentDirectory(hostDirectory) &&
|
||||
IsParentOf(fullAppRoot, hostDirectory))
|
||||
{
|
||||
return fullAppRoot;
|
||||
}
|
||||
|
||||
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fullAppRoot;
|
||||
}
|
||||
|
||||
return hostDirectory ?? fullAppRoot;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildForwardedArguments(
|
||||
CommandContext context,
|
||||
string packageRoot,
|
||||
AppVersionInfo versionInfo)
|
||||
{
|
||||
var arguments = new List<string>();
|
||||
|
||||
for (var index = 0; index < context.RawArgs.Count; index++)
|
||||
{
|
||||
var arg = context.RawArgs[index];
|
||||
|
||||
if (index == 0 &&
|
||||
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index == 1 &&
|
||||
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var key = arg[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
key = key[..equalsIndex];
|
||||
}
|
||||
|
||||
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (equalsIndex < 0 &&
|
||||
index + 1 < context.RawArgs.Count &&
|
||||
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
arguments.Add(arg);
|
||||
}
|
||||
|
||||
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
|
||||
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private static bool IsAppDeploymentDirectory(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
|
||||
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsParentOf(string parent, string child)
|
||||
{
|
||||
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return childPath.StartsWith(
|
||||
parentPath + Path.DirectorySeparatorChar,
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -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,124 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
|
||||
|
||||
internal static class LauncherDebugSettingsStore
|
||||
{
|
||||
private const string DevModeFileName = "dev-mode.flag";
|
||||
private const string CustomHostPathFileName = "custom-host-path.txt";
|
||||
private const string LegacyDevModeFileName = "devmode.config";
|
||||
private const string LegacyCustomHostPathFileName = "custom-host-path.config";
|
||||
|
||||
internal static string? ConfigBaseDirectoryOverride { get; set; }
|
||||
|
||||
public static string ConfigBaseDirectory => ConfigBaseDirectoryOverride ?? ResolveConfigBaseDirectory();
|
||||
|
||||
public static LauncherDebugSettings Load()
|
||||
{
|
||||
return new LauncherDebugSettings(
|
||||
LoadDevModeState(),
|
||||
LoadCustomHostPath());
|
||||
}
|
||||
|
||||
public static bool IsDevModeEnabled() => Load().DevModeEnabled;
|
||||
|
||||
public static string? GetSavedCustomHostPath() => Load().CustomHostPath;
|
||||
|
||||
public static void Save(LauncherDebugSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ConfigBaseDirectory);
|
||||
File.WriteAllText(GetPath(DevModeFileName), settings.DevModeEnabled.ToString());
|
||||
File.WriteAllText(GetPath(CustomHostPathFileName), settings.CustomHostPath ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to save launcher debug settings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void SaveDevModeState(bool enabled)
|
||||
{
|
||||
var current = Load();
|
||||
Save(current with { DevModeEnabled = enabled });
|
||||
}
|
||||
|
||||
public static void SaveCustomHostPath(string? customHostPath)
|
||||
{
|
||||
var current = Load();
|
||||
Save(current with { CustomHostPath = customHostPath });
|
||||
}
|
||||
|
||||
private static bool LoadDevModeState()
|
||||
{
|
||||
var newValue = TryReadText(GetPath(DevModeFileName));
|
||||
if (!string.IsNullOrWhiteSpace(newValue))
|
||||
{
|
||||
return TryParseDevMode(newValue);
|
||||
}
|
||||
|
||||
var legacyValue = TryReadText(GetPath(LegacyDevModeFileName));
|
||||
return !string.IsNullOrWhiteSpace(legacyValue) && TryParseDevMode(legacyValue);
|
||||
}
|
||||
|
||||
private static string? LoadCustomHostPath()
|
||||
{
|
||||
var newValue = TryReadText(GetPath(CustomHostPathFileName));
|
||||
if (!string.IsNullOrWhiteSpace(newValue))
|
||||
{
|
||||
return newValue.Trim();
|
||||
}
|
||||
|
||||
var legacyValue = TryReadText(GetPath(LegacyCustomHostPathFileName));
|
||||
return string.IsNullOrWhiteSpace(legacyValue) ? null : legacyValue.Trim();
|
||||
}
|
||||
|
||||
private static bool TryParseDevMode(string value)
|
||||
{
|
||||
var normalized = value.Trim();
|
||||
return normalized == "1" ||
|
||||
normalized.Equals("true", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("on", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? TryReadText(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to read launcher debug setting '{path}': {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetPath(string fileName) => Path.Combine(ConfigBaseDirectory, fileName);
|
||||
|
||||
private static string ResolveConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, ".launcher");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
540
LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs
Normal file
540
LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs
Normal file
@@ -0,0 +1,540 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class StartupAttemptRegistry
|
||||
{
|
||||
private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _statePath;
|
||||
private readonly string _mutexName;
|
||||
private string? _ownedAttemptId;
|
||||
|
||||
public StartupAttemptRegistry()
|
||||
: this(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
".launcher",
|
||||
"state",
|
||||
"startup-attempt.json"))
|
||||
{
|
||||
}
|
||||
|
||||
internal StartupAttemptRegistry(string statePath)
|
||||
{
|
||||
_statePath = statePath;
|
||||
_mutexName = $"LanMountainDesktop.Launcher.StartupAttempt.{ComputePathHash(statePath)}";
|
||||
}
|
||||
|
||||
public StartupAttemptRecord StartOwnedAttempt(
|
||||
int hostPid,
|
||||
string launchSource,
|
||||
string successPolicy,
|
||||
StartupStage stage,
|
||||
string? message)
|
||||
{
|
||||
var record = new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = Guid.NewGuid().ToString("N"),
|
||||
HostPid = hostPid,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
LaunchSource = launchSource,
|
||||
SuccessPolicy = successPolicy,
|
||||
LastObservedStage = stage,
|
||||
LastObservedMessage = message ?? string.Empty,
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
HeartbeatAtUtc = DateTimeOffset.UtcNow,
|
||||
State = StartupAttemptState.Pending
|
||||
};
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
SaveUnsafe(record);
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
});
|
||||
|
||||
return Clone(record);
|
||||
}
|
||||
|
||||
public bool TryReserveCoordinator(
|
||||
string launchSource,
|
||||
string successPolicy,
|
||||
string coordinatorPipeName,
|
||||
out StartupAttemptRecord reservedAttempt,
|
||||
out StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
StartupAttemptRecord? reserved = null;
|
||||
StartupAttemptRecord? active = null;
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var existing = LoadUnsafe();
|
||||
if (existing is not null && IsCoordinatorLive(existing))
|
||||
{
|
||||
active = Clone(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing is not null && IsRecoverableCoordinatorAttempt(existing))
|
||||
{
|
||||
existing.CoordinatorPid = Environment.ProcessId;
|
||||
existing.CoordinatorPipeName = coordinatorPipeName;
|
||||
existing.HeartbeatAtUtc = DateTimeOffset.UtcNow;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
if (existing.HostPid <= 0)
|
||||
{
|
||||
existing.ReservedBeforeHostStart = true;
|
||||
}
|
||||
|
||||
if (existing.State == StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
existing.State = StartupAttemptState.SoftTimeout;
|
||||
}
|
||||
|
||||
_ownedAttemptId = existing.AttemptId;
|
||||
SaveUnsafe(existing);
|
||||
reserved = Clone(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = Guid.NewGuid().ToString("N"),
|
||||
HostPid = 0,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
CoordinatorPipeName = coordinatorPipeName,
|
||||
LaunchSource = launchSource,
|
||||
SuccessPolicy = successPolicy,
|
||||
LastObservedStage = StartupStage.Initializing,
|
||||
LastObservedMessage = "Launcher coordinator reserved startup ownership.",
|
||||
StartedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
HeartbeatAtUtc = now,
|
||||
ReservedBeforeHostStart = true,
|
||||
State = StartupAttemptState.Pending
|
||||
};
|
||||
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
SaveUnsafe(record);
|
||||
reserved = Clone(record);
|
||||
});
|
||||
|
||||
reservedAttempt = reserved ?? new StartupAttemptRecord();
|
||||
activeCoordinatorAttempt = active;
|
||||
return reserved is not null;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? GetOwnedAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null && string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetLiveCoordinatorAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null && IsCoordinatorLive(record))
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetLatestAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null)
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord AssignOwnedHostProcess(
|
||||
int hostPid,
|
||||
StartupStage stage,
|
||||
string? message)
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.HostPid = hostPid;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
record.ReservedBeforeHostStart = false;
|
||||
result = Clone(record);
|
||||
});
|
||||
|
||||
return result ?? StartOwnedAttempt(
|
||||
hostPid,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
stage,
|
||||
message);
|
||||
}
|
||||
|
||||
public bool AdoptAttempt(string attemptId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attemptId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var adopted = false;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null || !string.Equals(record.AttemptId, attemptId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsAttachable(record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
if (record.State == StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
record.State = StartupAttemptState.SoftTimeout;
|
||||
}
|
||||
|
||||
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SaveUnsafe(record);
|
||||
adopted = true;
|
||||
});
|
||||
|
||||
return adopted;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetAttachableAttempt(string launchSource, string successPolicy)
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null ||
|
||||
!IsAttachable(record) ||
|
||||
!string.Equals(record.LaunchSource, launchSource, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(record.SuccessPolicy, successPolicy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result = Clone(record);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void MarkOwnedIpcConnected()
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.IpcConnected = true;
|
||||
record.PublicIpcConnected = true;
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? string.Empty;
|
||||
if (ipcConnected)
|
||||
{
|
||||
record.IpcConnected = true;
|
||||
record.PublicIpcConnected = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateOwnedCoordinatorHeartbeat(LauncherCoordinatorStatus status)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.CoordinatorPid = Environment.ProcessId;
|
||||
record.HeartbeatAtUtc = DateTimeOffset.UtcNow;
|
||||
record.LastObservedStage = status.LastObservedStage;
|
||||
record.LastObservedMessage = status.LastObservedMessage;
|
||||
record.IpcConnected = status.PublicIpcConnected;
|
||||
record.PublicIpcConnected = status.PublicIpcConnected;
|
||||
record.ShellStatus = status.ShellStatus?.ShellState ?? status.State;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedSoftTimeout(string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.SoftTimeout;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedWaitingForShell(string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
record.State = StartupAttemptState.WaitingForShell;
|
||||
}
|
||||
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedDetachedWaiting()
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout)
|
||||
{
|
||||
record.State = StartupAttemptState.DetachedWaiting;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedSucceeded(StartupStage stage, string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.Succeeded;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedFailed(StartupStage stage, string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.Failed;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateOwned(Action<StartupAttemptRecord> update)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
update(record);
|
||||
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SaveUnsafe(record);
|
||||
});
|
||||
}
|
||||
|
||||
private void ExecuteWithLock(Action action)
|
||||
{
|
||||
using var mutex = new Mutex(false, _mutexName);
|
||||
var hasHandle = false;
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
hasHandle = true;
|
||||
}
|
||||
|
||||
if (!hasHandle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hasHandle)
|
||||
{
|
||||
mutex.ReleaseMutex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StartupAttemptRecord? LoadUnsafe()
|
||||
{
|
||||
if (!File.Exists(_statePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_statePath);
|
||||
return JsonSerializer.Deserialize<StartupAttemptRecord>(json, SerializerOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveUnsafe(StartupAttemptRecord record)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_statePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions));
|
||||
}
|
||||
|
||||
private static bool IsAttachable(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.HostPid, out _);
|
||||
}
|
||||
|
||||
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.HostPid <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.HostPid, out _);
|
||||
}
|
||||
|
||||
private static bool IsCoordinatorLive(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.CoordinatorPid <= 0 ||
|
||||
string.IsNullOrWhiteSpace(record.CoordinatorPipeName) ||
|
||||
DateTimeOffset.UtcNow - record.HeartbeatAtUtc > CoordinatorHeartbeatTimeout)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.CoordinatorPid, out _);
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId, out Process? process)
|
||||
{
|
||||
process = null;
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
process?.Dispose();
|
||||
process = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputePathHash(string statePath)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(statePath.ToLowerInvariant()));
|
||||
return Convert.ToHexString(bytes[..8]);
|
||||
}
|
||||
|
||||
private static StartupAttemptRecord Clone(StartupAttemptRecord record)
|
||||
{
|
||||
return new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = record.AttemptId,
|
||||
HostPid = record.HostPid,
|
||||
CoordinatorPid = record.CoordinatorPid,
|
||||
CoordinatorPipeName = record.CoordinatorPipeName,
|
||||
StartedAtUtc = record.StartedAtUtc,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||
HeartbeatAtUtc = record.HeartbeatAtUtc,
|
||||
LaunchSource = record.LaunchSource,
|
||||
SuccessPolicy = record.SuccessPolicy,
|
||||
LastObservedStage = record.LastObservedStage,
|
||||
LastObservedMessage = record.LastObservedMessage,
|
||||
IpcConnected = record.IpcConnected,
|
||||
PublicIpcConnected = record.PublicIpcConnected,
|
||||
ShellStatus = record.ShellStatus,
|
||||
ReservedBeforeHostStart = record.ReservedBeforeHostStart,
|
||||
State = record.State
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
<views:OobeWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid x:Name="ContentGrid">
|
||||
<Grid x:Name="ContentGrid"
|
||||
Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform Y="24" />
|
||||
</Grid.RenderTransform>
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Margin="48" RowDefinitions="*,Auto">
|
||||
<!-- 中央内容区域 -->
|
||||
|
||||
@@ -9,26 +9,18 @@ using Avalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// OOBE(首次使用体验)窗口 - 欢迎页面
|
||||
/// </summary>
|
||||
public partial class OobeWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
private bool _isTransitioning = false;
|
||||
private bool _isTransitioning;
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再初始化
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
|
||||
@@ -45,31 +37,29 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件 - 播放入场动画
|
||||
/// </summary>
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
|
||||
await PlayEntranceAnimationAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放入场动画
|
||||
/// </summary>
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取内容元素
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接返回
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡入动画
|
||||
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
|
||||
contentGrid.RenderTransform = translateTransform;
|
||||
|
||||
var offset = ResolveEntranceOffset();
|
||||
contentGrid.Opacity = 0;
|
||||
translateTransform.Y = offset;
|
||||
|
||||
var fadeInAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
@@ -89,7 +79,6 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
};
|
||||
|
||||
// 创建向上滑动动画
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
@@ -98,7 +87,7 @@ public partial class OobeWindow : Window
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
|
||||
Setters = { new Setter(TranslateTransform.YProperty, offset) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
@@ -109,9 +98,9 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
};
|
||||
|
||||
// 应用动画
|
||||
await fadeInAnimation.RunAsync(contentGrid);
|
||||
await slideUpAnimation.RunAsync(contentGrid);
|
||||
await Task.WhenAll(
|
||||
fadeInAnimation.RunAsync(contentGrid),
|
||||
slideUpAnimation.RunAsync(translateTransform));
|
||||
|
||||
Console.WriteLine("[OobeWindow] Entrance animation completed");
|
||||
}
|
||||
@@ -121,27 +110,21 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户点击开始按钮
|
||||
/// </summary>
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
/// <summary>
|
||||
/// 进入按钮点击事件
|
||||
/// </summary>
|
||||
private async void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
_isTransitioning = true;
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTransitioning = true;
|
||||
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
|
||||
|
||||
try
|
||||
{
|
||||
// 播放退出动画
|
||||
await PlayExitAnimationAsync();
|
||||
|
||||
// 完成 OOBE
|
||||
_completionSource.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -151,9 +134,6 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放退出动画
|
||||
/// </summary>
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
try
|
||||
@@ -161,12 +141,10 @@ public partial class OobeWindow : Window
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接延迟后返回
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡出动画
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
@@ -194,4 +172,11 @@ public partial class OobeWindow : Window
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveEntranceOffset()
|
||||
{
|
||||
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
|
||||
var scaledOffset = boundsHeight * 0.05;
|
||||
return Math.Clamp(scaledOffset, 20, 48);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,85 +3,92 @@
|
||||
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 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 RowDefinitions="*,Auto"
|
||||
Background="#0B0B0B">
|
||||
<Grid Grid.Row="0">
|
||||
<Grid x:Name="CompactHero"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="FullscreenHero"
|
||||
IsVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="24">
|
||||
<Border Width="240"
|
||||
Height="240"
|
||||
Background="Transparent">
|
||||
<Image Source="/Assets/logo_nightly.png"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="LanMountain Desktop"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#F6F7FB" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
</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,273 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 启动画面窗口 - 简洁设计
|
||||
/// </summary>
|
||||
public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
private int _versionTextClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugModeOpened = false;
|
||||
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
|
||||
private static readonly TimeSpan SlideAnimationDuration = TimeSpan.FromMilliseconds(260);
|
||||
|
||||
private readonly StartupVisualMode _mode;
|
||||
private int _versionTextClickCount;
|
||||
private bool _isDebugModeOpened;
|
||||
private bool _isOpened;
|
||||
private bool _layoutConfigured;
|
||||
private bool _dismissed;
|
||||
private PixelPoint _targetPosition;
|
||||
private PixelPoint _slideHiddenPosition;
|
||||
|
||||
public SplashWindow()
|
||||
: this(StartupVisualMode.Fade)
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再绑定事件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
public SplashWindow(StartupVisualMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
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)
|
||||
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 async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isOpened)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isOpened = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
Opacity = 0d;
|
||||
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Opacity = 1d;
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
await AnimateWindowPositionAsync(_slideHiddenPosition, _targetPosition, SlideAnimationDuration, EaseOutCubic).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DismissAsync()
|
||||
{
|
||||
if (_dismissed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dismissed = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
var from = Position;
|
||||
await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
|
||||
}
|
||||
else if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (IsVisible)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
var progress = ResolveProgress(stage);
|
||||
if (progress > 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = stage;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message) &&
|
||||
this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateStatus(string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void SetVersionInfo(string version, string codename)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("VersionText") is { } versionText)
|
||||
{
|
||||
versionText.Text = $"{version} ({codename})";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
if (!isDebugMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateStatus("[Debug Mode] Splash Preview");
|
||||
}
|
||||
|
||||
private void ConfigureForVisualMode()
|
||||
{
|
||||
if (_layoutConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_layoutConfigured = true;
|
||||
var compactHero = this.FindControl<Grid>("CompactHero");
|
||||
var fullscreenHero = this.FindControl<Grid>("FullscreenHero");
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
Background = new SolidColorBrush(Color.Parse("#0B0B0B"));
|
||||
Width = 480;
|
||||
Height = 320;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
return;
|
||||
}
|
||||
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
Background = Brushes.Black;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
|
||||
var screen = Screens?.Primary ?? Screens?.All.FirstOrDefault();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scale = Math.Max(screen?.Scaling ?? 1d, 0.01d);
|
||||
|
||||
Width = workingArea.Width / scale;
|
||||
Height = workingArea.Height / scale;
|
||||
_targetPosition = new PixelPoint(workingArea.X, workingArea.Y);
|
||||
_slideHiddenPosition = new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y);
|
||||
Position = _mode == StartupVisualMode.SlideSplash
|
||||
? _slideHiddenPosition
|
||||
: _targetPosition;
|
||||
}
|
||||
|
||||
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 +276,75 @@ 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 AnimateWindowPositionAsync(
|
||||
PixelPoint from,
|
||||
PixelPoint to,
|
||||
TimeSpan duration,
|
||||
Func<double, double> easing)
|
||||
{
|
||||
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 in UpdateProgress");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
});
|
||||
var currentX = (int)Math.Round(from.X + ((to.X - from.X) * progress));
|
||||
var currentY = (int)Math.Round(from.Y + ((to.Y - from.Y) * progress));
|
||||
Position = new PixelPoint(currentX, currentY);
|
||||
}, duration, easing).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新状态文本
|
||||
/// </summary>
|
||||
public void UpdateStatus(string message)
|
||||
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");
|
||||
if (statusText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
|
||||
return;
|
||||
}
|
||||
statusText.Text = message;
|
||||
});
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
return;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
while (stopwatch.Elapsed < duration)
|
||||
{
|
||||
var raw = stopwatch.Elapsed.TotalMilliseconds / duration.TotalMilliseconds;
|
||||
var progress = easing(Math.Clamp(raw, 0d, 1d));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(progress));
|
||||
await Task.Delay(16).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
private static double EaseInCubic(double value) => value * value * value;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -152,8 +152,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; } = [];
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class Program
|
||||
AppLogger.Initialize();
|
||||
DevPluginOptions.Parse(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
if (!singleInstance.IsPrimaryInstance)
|
||||
@@ -77,6 +77,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.");
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1290,6 +1290,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 +1341,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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
private readonly Mutex _mutex;
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _listenCts = new();
|
||||
private readonly ManualResetEventSlim _listenerReady = new(false);
|
||||
private bool _ownsMutex;
|
||||
private bool _disposed;
|
||||
private Task? _listenTask;
|
||||
@@ -64,6 +65,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
"SingleInstance",
|
||||
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
|
||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||
_listenerReady.Wait(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||
@@ -142,6 +144,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
}
|
||||
|
||||
_listenCts.Dispose();
|
||||
_listenerReady.Dispose();
|
||||
if (_ownsMutex)
|
||||
{
|
||||
try
|
||||
@@ -170,6 +173,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
_listenerReady.Set();
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var buffer = new byte[1];
|
||||
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -13,6 +13,7 @@ using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
@@ -201,7 +202,8 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? RenderModes[0];
|
||||
EnableSlideTransition = appSnapshot.EnableSlideTransition;
|
||||
ApplyTransitionPreferences(appSnapshot.EnableFadeTransition, appSnapshot.EnableSlideTransition);
|
||||
ShowInTaskbar = appSnapshot.ShowInTaskbar;
|
||||
_isInitializing = false;
|
||||
|
||||
RefreshPreview();
|
||||
@@ -234,9 +236,16 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedKeys.Contains(nameof(AppSettingsSnapshot.EnableSlideTransition)))
|
||||
if (changedKeys.Contains(nameof(AppSettingsSnapshot.EnableSlideTransition)) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFadeTransition)))
|
||||
{
|
||||
EnableSlideTransition = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).EnableSlideTransition;
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
ApplyTransitionPreferences(snapshot.EnableFadeTransition, snapshot.EnableSlideTransition);
|
||||
}
|
||||
|
||||
if (changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar)))
|
||||
{
|
||||
ShowInTaskbar = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,11 +266,23 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _enableFadeTransition = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _enableSlideTransition;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showInTaskbar;
|
||||
|
||||
public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
|
||||
|
||||
public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition;
|
||||
|
||||
public string FadeTransitionDescription => EnableSlideTransition
|
||||
? "滑动模式已启用,淡入淡出不可同时使用。"
|
||||
: "启用后,启动与恢复过程使用淡入淡出效果。";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pageTitle = string.Empty;
|
||||
|
||||
@@ -362,9 +383,29 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
}
|
||||
|
||||
partial void OnEnableSlideTransitionChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTransitionPreferences(EnableFadeTransition, value);
|
||||
}
|
||||
|
||||
partial void OnEnableFadeTransitionChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTransitionPreferences(value, EnableSlideTransition);
|
||||
}
|
||||
|
||||
partial void OnShowInTaskbarChanged(bool value)
|
||||
{
|
||||
if (_isInitializing) return;
|
||||
SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value);
|
||||
SaveField(nameof(AppSettingsSnapshot.ShowInTaskbar), value);
|
||||
}
|
||||
|
||||
private void SaveField<T>(string key, T value)
|
||||
@@ -379,6 +420,35 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
|
||||
}
|
||||
|
||||
private void SaveTransitionPreferences(bool enableFadeTransition, bool enableSlideTransition)
|
||||
{
|
||||
var normalized = StartupVisualPreferencesResolver.FromFlags(enableFadeTransition, enableSlideTransition);
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
snapshot.EnableFadeTransition = normalized.EnableFadeTransition;
|
||||
snapshot.EnableSlideTransition = normalized.EnableSlideTransition;
|
||||
ApplyTransitionPreferences(normalized.EnableFadeTransition, normalized.EnableSlideTransition);
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.EnableFadeTransition),
|
||||
nameof(AppSettingsSnapshot.EnableSlideTransition)
|
||||
]);
|
||||
}
|
||||
|
||||
private void ApplyTransitionPreferences(bool enableFadeTransition, bool enableSlideTransition)
|
||||
{
|
||||
var normalized = StartupVisualPreferencesResolver.FromFlags(enableFadeTransition, enableSlideTransition);
|
||||
var wasInitializing = _isInitializing;
|
||||
_isInitializing = true;
|
||||
EnableFadeTransition = normalized.EnableFadeTransition;
|
||||
EnableSlideTransition = normalized.EnableSlideTransition;
|
||||
_isInitializing = wasInitializing;
|
||||
OnPropertyChanged(nameof(IsFadeTransitionToggleEnabled));
|
||||
OnPropertyChanged(nameof(FadeTransitionDescription));
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateLanguageOptions()
|
||||
{
|
||||
return
|
||||
|
||||
@@ -256,18 +256,14 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// 打开设置窗口并导航到插件目录页面
|
||||
if (Application.Current is App app && app.SettingsWindowService is { } settingsWindowService)
|
||||
if (Application.Current is App app)
|
||||
{
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
var request = new SettingsWindowOpenRequest(
|
||||
Source: "FusedDesktopComponentLibrary",
|
||||
Owner: mainWindow,
|
||||
PageId: "plugin-catalog");
|
||||
settingsWindowService.Open(request);
|
||||
app.OpenIndependentSettingsModule("FusedDesktopComponentLibrary", "plugin-catalog");
|
||||
}
|
||||
|
||||
// 关闭所在窗口
|
||||
var window = this.FindAncestorOfType<Window>();
|
||||
window?.Close();
|
||||
var componentLibraryWindow = this.FindAncestorOfType<Window>();
|
||||
componentLibraryWindow?.Close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ using LanMountainDesktop.DesktopEditing;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
@@ -282,16 +281,7 @@ public partial class MainWindow
|
||||
CloseComponentLibraryWindow(reopenSettings: false);
|
||||
}
|
||||
|
||||
var app = Application.Current as App;
|
||||
if (app?.SettingsWindowService is { } settingsWindowService)
|
||||
{
|
||||
settingsWindowService.Toggle(new SettingsWindowOpenRequest(
|
||||
Source: "MainWindowTaskbar",
|
||||
Owner: this));
|
||||
return;
|
||||
}
|
||||
|
||||
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
|
||||
(Application.Current as App)?.OpenIndependentSettingsModule("MainWindowTaskbar");
|
||||
}
|
||||
|
||||
private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e)
|
||||
@@ -2861,34 +2851,6 @@ public partial class MainWindow
|
||||
CloseDetachedComponentLibraryWindow();
|
||||
}
|
||||
|
||||
public bool TryGetSettingsWindowAnchorBounds(out PixelRect anchorBounds)
|
||||
{
|
||||
anchorBounds = default;
|
||||
if (!IsVisible || BottomTaskbarContainer is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var origin = BottomTaskbarContainer.TranslatePoint(new Point(0, 0), this);
|
||||
if (origin is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var scale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||
var width = (int)Math.Round(BottomTaskbarContainer.Bounds.Width * scale);
|
||||
var height = (int)Math.Round(BottomTaskbarContainer.Bounds.Height * scale);
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var x = Position.X + (int)Math.Round(origin.Value.X * scale);
|
||||
var y = Position.Y + (int)Math.Round(origin.Value.Y * scale);
|
||||
anchorBounds = new PixelRect(x, y, width, height);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CollapseComponentLibraryPanel()
|
||||
{
|
||||
// Animate component library panel collapsing downward
|
||||
|
||||
@@ -79,6 +79,8 @@ public partial class MainWindow
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
@@ -688,6 +690,11 @@ public partial class MainWindow
|
||||
StatusBarShadowEnabled = _statusBarShadowEnabled,
|
||||
StatusBarShadowColor = _statusBarShadowColor,
|
||||
StatusBarShadowOpacity = _statusBarShadowOpacity,
|
||||
EnableThreeFingerSwipe = existingSnapshot.EnableThreeFingerSwipe,
|
||||
EnableFadeTransition = existingSnapshot.EnableFadeTransition,
|
||||
EnableSlideTransition = existingSnapshot.EnableSlideTransition,
|
||||
ShowInTaskbar = existingSnapshot.ShowInTaskbar,
|
||||
EnableFusedDesktop = existingSnapshot.EnableFusedDesktop,
|
||||
DisabledPluginIds = existingSnapshot.DisabledPluginIds,
|
||||
StudyFrameMs = existingSnapshot.StudyFrameMs,
|
||||
StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
UseLayoutRounding="True"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Transparent"
|
||||
Title="LanMountainDesktop">
|
||||
|
||||
<Design.DataContext>
|
||||
@@ -99,12 +100,17 @@
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform />
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Intro}" Easing="0.05,0.75,0.10,1.00" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
</Grid.RenderTransform>
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" Easing="0.05,0.75,0.10,1.00" />
|
||||
<DoubleTransition Property="TranslateTransform.X" Duration="{StaticResource FluttermotionToken.Duration.Intro}" Easing="0.05,0.75,0.10,1.00" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
@@ -23,13 +24,14 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private enum WallpaperMediaType
|
||||
{
|
||||
@@ -134,6 +136,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private string _gridSpacingPreset = "Relaxed";
|
||||
private bool _isSlideAnimationActive;
|
||||
private TranslateTransform? _desktopPageSlideTransform;
|
||||
private PixelPoint? _preparedWindowTargetPosition;
|
||||
private PixelPoint? _preparedWindowHiddenPosition;
|
||||
private string _statusBarSpacingMode = "Relaxed";
|
||||
private int _statusBarCustomSpacingPercent = 12;
|
||||
private bool _statusBarClockTransparentBackground;
|
||||
@@ -450,6 +454,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
MinShortSideCells,
|
||||
MaxShortSideCells);
|
||||
|
||||
ShowInTaskbar = snapshot.ShowInTaskbar;
|
||||
|
||||
_gridSpacingPreset = _gridSettingsService.NormalizeSpacingPreset(snapshot.GridSpacingPreset);
|
||||
|
||||
_desktopEdgeInsetPercent = Math.Clamp(snapshot.DesktopEdgeInsetPercent, MinEdgeInsetPercent, MaxEdgeInsetPercent);
|
||||
@@ -860,65 +866,122 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
return _desktopPageSlideTransform;
|
||||
}
|
||||
|
||||
internal bool ShouldUseFullscreenWindow()
|
||||
{
|
||||
return GetStartupVisualPreferences().Mode != StartupVisualMode.SlideSplash;
|
||||
}
|
||||
|
||||
internal void EnsureForegroundWindowLayout()
|
||||
{
|
||||
if (!IsSlideTransitionEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var layout = ResolveWindowAnimationLayout();
|
||||
ApplyWindowAnimationLayout(layout);
|
||||
Position = layout.VisiblePosition;
|
||||
}
|
||||
|
||||
private async void SlideOutAndMinimizeAsync()
|
||||
{
|
||||
_isSlideAnimationActive = true;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
|
||||
var useSlide = IsSlideTransitionEnabled();
|
||||
var slideTransform = GetDesktopPageSlideTransform();
|
||||
var preferences = GetStartupVisualPreferences();
|
||||
WindowAnimationLayout? slideLayout = null;
|
||||
|
||||
if (useSlide)
|
||||
if (preferences.Mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
slideTransform.X = Bounds.Width;
|
||||
slideLayout = ResolveWindowAnimationLayout();
|
||||
ApplyWindowAnimationLayout(slideLayout.Value);
|
||||
await AnimateWindowPositionAsync(
|
||||
Position,
|
||||
slideLayout.Value.HiddenPosition,
|
||||
FluttermotionToken.Intro).ConfigureAwait(false);
|
||||
}
|
||||
else if (preferences.Mode == StartupVisualMode.Fade)
|
||||
{
|
||||
DesktopPage.Opacity = 0;
|
||||
await Task.Delay(FluttermotionToken.Page);
|
||||
}
|
||||
|
||||
DesktopPage.Opacity = 0;
|
||||
|
||||
await Task.Delay(useSlide
|
||||
? FluttermotionToken.Intro
|
||||
: FluttermotionToken.Page);
|
||||
|
||||
if (!_isSlideAnimationActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WindowState = WindowState.Minimized;
|
||||
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
if (snapshot.ShowInTaskbar)
|
||||
{
|
||||
WindowState = WindowState.Minimized;
|
||||
}
|
||||
else if (Application.Current is App app)
|
||||
{
|
||||
app.HideMainWindowToTray(this, "MinimizeAction");
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowState = WindowState.Minimized;
|
||||
}
|
||||
|
||||
slideTransform.X = 0;
|
||||
DesktopPage.Opacity = 1;
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
_isSlideAnimationActive = false;
|
||||
if (slideLayout is { } layout)
|
||||
{
|
||||
Position = layout.VisiblePosition;
|
||||
}
|
||||
}
|
||||
|
||||
public void PrepareEnterAnimation()
|
||||
{
|
||||
_isSlideAnimationActive = false;
|
||||
|
||||
var useSlide = IsSlideTransitionEnabled();
|
||||
var slideTransform = GetDesktopPageSlideTransform();
|
||||
var preferences = GetStartupVisualPreferences();
|
||||
_preparedWindowTargetPosition = null;
|
||||
_preparedWindowHiddenPosition = null;
|
||||
|
||||
var savedTransitions = DesktopPage.Transitions;
|
||||
DesktopPage.Transitions = null;
|
||||
|
||||
DesktopPage.Opacity = 0;
|
||||
|
||||
if (useSlide)
|
||||
if (preferences.Mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : 1920;
|
||||
var layout = ResolveWindowAnimationLayout();
|
||||
_preparedWindowTargetPosition = layout.VisiblePosition;
|
||||
_preparedWindowHiddenPosition = layout.HiddenPosition;
|
||||
ApplyWindowAnimationLayout(layout);
|
||||
Position = layout.HiddenPosition;
|
||||
DesktopPage.Opacity = 1;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
_isSlideAnimationActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopPage.Transitions = savedTransitions;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
_isSlideAnimationActive = true;
|
||||
if (preferences.Mode == StartupVisualMode.Fade)
|
||||
{
|
||||
var savedTransitions = DesktopPage.Transitions;
|
||||
DesktopPage.Transitions = null;
|
||||
DesktopPage.Opacity = 0;
|
||||
DesktopPage.Transitions = savedTransitions;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
_isSlideAnimationActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopPage.Opacity = 1;
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
}
|
||||
|
||||
public void PlayEnterAnimation()
|
||||
{
|
||||
var slideTransform = GetDesktopPageSlideTransform();
|
||||
var preferences = GetStartupVisualPreferences();
|
||||
if (preferences.Mode == StartupVisualMode.SlideSplash &&
|
||||
_preparedWindowTargetPosition is { } targetPosition &&
|
||||
_preparedWindowHiddenPosition is { } hiddenPosition)
|
||||
{
|
||||
_ = PlayWindowEnterAnimationAsync(hiddenPosition, targetPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopPage.Opacity = 1;
|
||||
slideTransform.X = 0;
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
_isSlideAnimationActive = false;
|
||||
}
|
||||
@@ -930,10 +993,67 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
return snapshot.EnableSlideTransition;
|
||||
return GetStartupVisualPreferences().Mode == StartupVisualMode.SlideSplash;
|
||||
}
|
||||
|
||||
private StartupVisualPreferences GetStartupVisualPreferences()
|
||||
{
|
||||
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
return StartupVisualPreferencesResolver.FromFlags(
|
||||
snapshot.EnableFadeTransition,
|
||||
snapshot.EnableSlideTransition);
|
||||
}
|
||||
|
||||
private WindowAnimationLayout ResolveWindowAnimationLayout()
|
||||
{
|
||||
var screen = Screens.ScreenFromVisual(this) ?? Screens.Primary ?? Screens.All.FirstOrDefault();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scaling = Math.Max(screen?.Scaling ?? 1d, 0.01d);
|
||||
|
||||
return new WindowAnimationLayout(
|
||||
new PixelPoint(workingArea.X, workingArea.Y),
|
||||
new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y),
|
||||
new Size(workingArea.Width / scaling, workingArea.Height / scaling));
|
||||
}
|
||||
|
||||
private void ApplyWindowAnimationLayout(WindowAnimationLayout layout)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
Width = layout.WindowSize.Width;
|
||||
Height = layout.WindowSize.Height;
|
||||
}
|
||||
|
||||
private async Task PlayWindowEnterAnimationAsync(PixelPoint hiddenPosition, PixelPoint targetPosition)
|
||||
{
|
||||
Position = hiddenPosition;
|
||||
await AnimateWindowPositionAsync(hiddenPosition, targetPosition, FluttermotionToken.Intro);
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
_isSlideAnimationActive = false;
|
||||
}
|
||||
|
||||
private async Task AnimateWindowPositionAsync(PixelPoint from, PixelPoint to, TimeSpan duration)
|
||||
{
|
||||
var totalMilliseconds = Math.Max(duration.TotalMilliseconds, 1d);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
while (stopwatch.Elapsed < duration)
|
||||
{
|
||||
var progress = Math.Clamp(stopwatch.Elapsed.TotalMilliseconds / totalMilliseconds, 0d, 1d);
|
||||
var eased = 1d - Math.Pow(1d - progress, 3d);
|
||||
var x = (int)Math.Round(from.X + ((to.X - from.X) * eased));
|
||||
var y = (int)Math.Round(from.Y + ((to.Y - from.Y) * eased));
|
||||
Position = new PixelPoint(x, y);
|
||||
await Task.Delay(16).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Position = to;
|
||||
}
|
||||
|
||||
private readonly record struct WindowAnimationLayout(
|
||||
PixelPoint VisiblePosition,
|
||||
PixelPoint HiddenPosition,
|
||||
Size WindowSize);
|
||||
|
||||
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property != WindowStateProperty)
|
||||
@@ -941,7 +1061,35 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
return;
|
||||
}
|
||||
|
||||
if (WindowState is WindowState.Minimized or WindowState.FullScreen)
|
||||
var newState = (WindowState)e.NewValue!;
|
||||
var oldState = (WindowState)e.OldValue!;
|
||||
|
||||
if (oldState == WindowState.Minimized && newState != WindowState.Minimized)
|
||||
{
|
||||
PrepareEnterAnimation();
|
||||
|
||||
if (ShouldUseFullscreenWindow())
|
||||
{
|
||||
if (newState != WindowState.FullScreen)
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
}
|
||||
}
|
||||
else if (newState == WindowState.Minimized)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (newState == WindowState.Minimized ||
|
||||
(ShouldUseFullscreenWindow() && newState == WindowState.FullScreen))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -960,7 +1108,10 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
|
||||
if (WindowState is not (WindowState.Minimized or WindowState.FullScreen))
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
if (ShouldUseFullscreenWindow())
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<!-- 区域设置分组 -->
|
||||
<controls:IconText Icon="Globe"
|
||||
Text="{Binding BasicHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
@@ -76,7 +75,6 @@
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<!-- 运行时设置分组 -->
|
||||
<controls:IconText Icon="DeveloperBoard"
|
||||
Text="{Binding RuntimeHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
@@ -106,8 +104,20 @@
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="滑入滑出过渡效果"
|
||||
Description="启用后,进入和退出桌面时使用滑入滑出动画(仅 Windows)"
|
||||
<ui:SettingsExpander Header="淡入淡出效果"
|
||||
Description="{Binding FadeTransitionDescription}"
|
||||
IsVisible="{Binding IsSlideTransitionAvailable}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowUpload" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding EnableFadeTransition}"
|
||||
IsEnabled="{Binding IsFadeTransitionToggleEnabled}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="启动滑入滑出效果"
|
||||
Description="启用后,启动和恢复时从屏幕右侧边缘滑入或滑出,仅 Windows 可用。"
|
||||
IsVisible="{Binding IsSlideTransitionAvailable}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowRight" />
|
||||
@@ -117,6 +127,16 @@
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="桌面主窗口在任务栏显示图标"
|
||||
Description="仅控制桌面主窗口在系统任务栏中的图标显示,不影响设置窗口。">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Window" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding ShowInTaskbar}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
|
||||
<assemblyIdentity version="0.0.0.0" name="LanMountainDesktop.Desktop"/>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
|
||||
@@ -40,7 +40,7 @@ Current built-in `[IpcPublic]` contracts:
|
||||
- `IPublicAppInfoService`
|
||||
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
|
||||
- `IPublicShellControlService`
|
||||
- Allows external .NET clients to activate the shell, open settings, request restart, and request exit.
|
||||
- Allows external .NET clients to query shell status, activate the shell, repair tray readiness, repair taskbar entry visibility, open settings, request restart, and request exit.
|
||||
- `IPublicPluginCatalogService`
|
||||
- Returns the merged public IPC catalog snapshot exposed by Host.
|
||||
|
||||
@@ -77,6 +77,8 @@ Launcher no longer depends on the previous custom named-pipe length-prefixed pro
|
||||
|
||||
This means Splash/OOBE is now just another IPC consumer on the same base transport used by external integrators.
|
||||
|
||||
Launcher-to-launcher de-duplication is intentionally separate from Host Public IPC. The active Launcher coordinator uses a per-user local pipe and `startup-attempt.json` heartbeat so secondary Launchers attach to the coordinator before any host process can be started twice.
|
||||
|
||||
## Plugin Public IPC Contribution Model
|
||||
|
||||
Plugins can contribute new external IPC services in two ways:
|
||||
|
||||
@@ -569,3 +569,7 @@ Launcher now consumes Host startup telemetry from the unified public IPC stack:
|
||||
- Launcher connects through `LanMountainDesktopIpcClient`
|
||||
|
||||
The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.
|
||||
|
||||
## Coordinator Guard
|
||||
|
||||
Launcher also owns a small per-user local coordinator used only between Launcher processes. It reserves `startup-attempt.json` before host launch, publishes a heartbeat, and exposes a local coordinator pipe for secondary Launchers. A secondary Launcher must attach to that coordinator or activate the existing Host through Public IPC instead of starting another Host process. See [Launcher Coordinator](LAUNCHER_COORDINATOR.md).
|
||||
|
||||
31
docs/LAUNCHER_COORDINATOR.md
Normal file
31
docs/LAUNCHER_COORDINATOR.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Launcher Coordinator
|
||||
|
||||
LanMountainDesktop Launcher uses a per-user coordinator to prevent duplicate host startup.
|
||||
|
||||
## Rules
|
||||
|
||||
- A Launcher reserves `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before starting the host.
|
||||
- The active record stores coordinator pid, coordinator pipe name, heartbeat, host pid, Public IPC state, and shell status.
|
||||
- Only the active coordinator may start the host process.
|
||||
- Secondary Launchers attach to the coordinator and request desktop activation.
|
||||
- A coordinator is considered live while its pid exists and its heartbeat is newer than `10s`.
|
||||
- Normal launch probes Host Public IPC first; if the host is already running, Launcher activates it and exits.
|
||||
|
||||
## Tray And Taskbar
|
||||
|
||||
- Tray icon and tray menu are mandatory and are not controlled by user settings.
|
||||
- Tray watchdog starts with the shell and runs until process exit.
|
||||
- `ShowInTaskbar=true` affects only the main-window taskbar entry.
|
||||
- When `ShowInTaskbar=true`, background mode uses a minimized taskbar entry while keeping tray visible.
|
||||
- Pure `TrayOnly` is allowed only when `ShowInTaskbar=false` and tray is ready.
|
||||
|
||||
## Public Shell IPC
|
||||
|
||||
Launcher and external callers can use:
|
||||
|
||||
- `GetShellStatusAsync()`
|
||||
- `ActivateMainWindowWithStatusAsync()`
|
||||
- `EnsureTrayReadyAsync()`
|
||||
- `EnsureTaskbarEntryAsync()`
|
||||
|
||||
These APIs report process, shell, tray, taskbar, and activation state separately so callers do not infer health from window visibility alone.
|
||||
28
docs/LAUNCHER_STARTUP_VISUALS.md
Normal file
28
docs/LAUNCHER_STARTUP_VISUALS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Launcher Startup Visuals
|
||||
|
||||
This supplement records the startup rules that are shared by the launcher and the desktop host.
|
||||
|
||||
## Timeout behavior
|
||||
|
||||
- `30 seconds` is a soft timeout.
|
||||
- Soft timeout means `still starting`, not `failed`.
|
||||
- When the host process is alive or Public IPC is connected, Launcher keeps waiting and avoids launching another host process.
|
||||
- `120 seconds` is the hard timeout for `desktop_not_visible`.
|
||||
|
||||
## Visual mode resolution
|
||||
|
||||
- `EnableSlideTransition = true` resolves to `SlideSplash` and forces `EnableFadeTransition = false`.
|
||||
- `EnableSlideTransition = false` and `EnableFadeTransition = false` resolves to `StaticSplash`.
|
||||
- `EnableSlideTransition = false` and `EnableFadeTransition = true` resolves to `Fade`.
|
||||
|
||||
## Fullscreen splash rules
|
||||
|
||||
- Fullscreen splash uses the shared `logo_nightly.png` asset.
|
||||
- Slide splash enters from the right edge of the target screen and exits back to the right edge.
|
||||
- Static splash uses the same fullscreen black surface without motion.
|
||||
|
||||
## Recovery rules
|
||||
|
||||
- Closing Launcher during startup does not cancel the startup attempt.
|
||||
- Relaunching Launcher attaches to the active attempt instead of spawning a second desktop process.
|
||||
- If a host process is still alive during failure handling, Launcher offers activation or continued waiting before any retry.
|
||||
@@ -1,15 +1,47 @@
|
||||
# 生成版本信息文件
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputPath,
|
||||
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Version,
|
||||
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Codename = "Administrate"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Normalize-ArgumentValue {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$trimmed = $Value.Trim()
|
||||
if ($trimmed.Length -ge 2) {
|
||||
$first = $trimmed[0]
|
||||
$last = $trimmed[$trimmed.Length - 1]
|
||||
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
|
||||
return $trimmed.Substring(1, $trimmed.Length - 2).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
return $trimmed
|
||||
}
|
||||
|
||||
$OutputPath = Normalize-ArgumentValue -Value $OutputPath
|
||||
$Version = Normalize-ArgumentValue -Value $Version
|
||||
$Codename = Normalize-ArgumentValue -Value $Codename
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
throw "OutputPath is required."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||
throw "Version is required."
|
||||
}
|
||||
|
||||
$versionInfo = @{
|
||||
Version = $Version
|
||||
Codename = $Codename
|
||||
@@ -18,11 +50,15 @@ $versionInfo = @{
|
||||
$json = $versionInfo | ConvertTo-Json -Compress
|
||||
$dir = Split-Path -Parent $OutputPath
|
||||
|
||||
if (!(Test-Path $dir)) {
|
||||
if ([string]::IsNullOrWhiteSpace($dir)) {
|
||||
throw "OutputPath must include a directory: $OutputPath"
|
||||
}
|
||||
|
||||
if (!(Test-Path -LiteralPath $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
|
||||
Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8
|
||||
Write-Host "Generated version file: $OutputPath" -ForegroundColor Green
|
||||
Write-Host " Version: $Version" -ForegroundColor Gray
|
||||
Write-Host " Codename: $Codename" -ForegroundColor Gray
|
||||
|
||||
56
scripts/Set-ReleaseVersion.ps1
Normal file
56
scripts/Set-ReleaseVersion.ps1
Normal file
@@ -0,0 +1,56 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AssemblyVersion
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Update-XmlNodeValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$XPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Value,
|
||||
|
||||
[hashtable]$NamespaceMap = @{}
|
||||
)
|
||||
|
||||
[xml]$document = Get-Content -Path $Path -Raw
|
||||
$navigator = $document.CreateNavigator()
|
||||
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($navigator.NameTable)
|
||||
foreach ($entry in $NamespaceMap.GetEnumerator()) {
|
||||
$namespaceManager.AddNamespace($entry.Key, $entry.Value)
|
||||
}
|
||||
|
||||
$node = $document.SelectSingleNode($XPath, $namespaceManager)
|
||||
if ($null -eq $node) {
|
||||
throw "Node '$XPath' was not found in '$Path'."
|
||||
}
|
||||
|
||||
$node.InnerText = $Value
|
||||
$document.Save($Path)
|
||||
}
|
||||
|
||||
$projectFiles = @(
|
||||
'Directory.Build.props',
|
||||
'LanMountainDesktop/LanMountainDesktop.csproj',
|
||||
'LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj',
|
||||
'LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj'
|
||||
)
|
||||
|
||||
foreach ($projectFile in $projectFiles) {
|
||||
Update-XmlNodeValue -Path $projectFile -XPath '/Project/PropertyGroup/Version' -Value $Version
|
||||
}
|
||||
|
||||
$manifestNamespace = @{ asm = 'urn:schemas-microsoft-com:asm.v1' }
|
||||
Update-XmlNodeValue -Path 'LanMountainDesktop/app.manifest' -XPath '/asm:assembly/asm:assemblyIdentity/@version' -Value $AssemblyVersion -NamespaceMap $manifestNamespace
|
||||
Update-XmlNodeValue -Path 'LanMountainDesktop.Launcher/app.manifest' -XPath '/asm:assembly/asm:assemblyIdentity/@version' -Value $AssemblyVersion -NamespaceMap $manifestNamespace
|
||||
|
||||
Write-Host "Stamped release version metadata. Version=$Version AssemblyVersion=$AssemblyVersion"
|
||||
Reference in New Issue
Block a user