Compare commits

..

2 Commits

Author SHA1 Message Date
lincube
00747f33b2 Merge branch 'main' into codex/ipc 2026-04-22 10:25:08 +08:00
lincube
2c48b7b846 Add plugin isolation IPC scaffolding and host phase one docs 2026-04-22 10:15:07 +08:00
116 changed files with 2020 additions and 8613 deletions

View File

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

View File

@@ -119,13 +119,6 @@ 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 }}
@@ -371,13 +364,6 @@ 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 }}
@@ -559,13 +545,6 @@ 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 }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
# Launcher Coordinator And Always-On Tray Addendum
## Launcher-to-launcher coordination
- Launcher reserves startup ownership in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before it starts the host process.
- The reserved record includes `CoordinatorPid`, `CoordinatorPipeName`, `HeartbeatAtUtc`, `PublicIpcConnected`, `ShellStatus`, and `ReservedBeforeHostStart`.
- Only the active coordinator may call `Process.Start()` for the host. Secondary Launchers attach to the coordinator pipe and request desktop activation or status.
- If the coordinator heartbeat is newer than `10s` and the coordinator pid is alive, a new Launcher must not take over.
- If the coordinator is stale, the next Launcher may take over the same pending attempt instead of creating a second host attempt.
- Normal launches probe Host Public IPC first. If a host is already running, Launcher activates that instance and exits without starting another host.
## Finer shell status
- Public shell IPC exposes `GetShellStatusAsync()`, `ActivateMainWindowWithStatusAsync()`, `EnsureTrayReadyAsync()`, and `EnsureTaskbarEntryAsync()`.
- `PublicShellStatus` separates process, shell state, main-window visibility, tray health, taskbar-entry health, and Public IPC readiness.
- Launcher success/failure details must include coordinator pid, attempt id, host pid, Public IPC status, tray state, and taskbar usability when available.
## Always-on tray and taskbar repair
- The tray icon and menu are mandatory application-liveness indicators and are not controlled by user settings.
- Tray watchdog starts during shell initialization and keeps running until application exit.
- `ShowInTaskbar=true` means hidden/background states prefer `MinimizedToTaskbar`; it never disables the tray.
- `ShowInTaskbar=false` is the only mode that may enter pure `TrayOnly`, and only after `TrayReady`.
- When taskbar entry is requested but missing, shell repair recreates or shows the main window minimized with `ShowInTaskbar=true` while keeping the tray visible.
## Regression coverage
- Unit tests cover active coordinator rejection, stale heartbeat takeover, and host-pid assignment after a reserved attempt.
- Manual QA still needs multi-process Launcher concurrency and real tray loss simulation on Windows.

View File

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

View File

@@ -1,37 +0,0 @@
# Launcher Slow-Startup And Startup Visual Addendum
## New startup timing contract
- `30s` is a soft timeout, not a failure threshold.
- After `30s`, if the desktop process is still alive or Public IPC is connected, Launcher must stay in a waiting state and must not start another host process.
- `120s` is the hard timeout.
- Before returning `desktop_not_visible`, Launcher must attempt one foreground recovery through `ActivateMainWindowAsync()`.
## Startup attempt de-duplication
- Launcher persists the current startup attempt in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json`.
- A second Launcher process must attach to a live pending attempt instead of calling `Process.Start()` again.
- Closing the splash window does not cancel startup; it transitions the attempt into detached waiting and preserves recovery state for the next Launcher run.
## Startup visual modes
- `EnableSlideTransition = true` forces `StartupVisualMode.SlideSplash` and automatically disables fade.
- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`.
- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`.
## UX safeguards
- If the host process is still alive at failure time, the failure dialog must prefer:
- `Activate`
- `Wait`
- `Open Logs`
- `Exit`
- Retry is only valid when Launcher is not about to create a duplicate desktop process.
## Launcher coordinator guard
- Startup attempts are now reserved before host launch, so concurrent Launchers cannot all reach `Process.Start()`.
- A live coordinator is identified by `CoordinatorPid`, `CoordinatorPipeName`, and a heartbeat newer than `10s`.
- Secondary Launchers send `activate-desktop` or `attach` to the coordinator pipe and then exit with the coordinator status.
- If Host Public IPC is already available during a normal launch, Launcher activates the existing desktop and does not start a new host process.
- Public shell status now reports tray readiness and taskbar-entry usability separately, allowing Launcher to distinguish "running but hidden" from "not recoverable".

View File

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

View File

@@ -1,17 +0,0 @@
# Tray Menu Shutdown Addendum
## Requirements
- Tray menu `Exit App` must commit an irreversible host shutdown request.
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
## Acceptance
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
- Repeated tray clicks during shutdown are ignored and logged.
- Repeated component-library clicks focus the existing window instead of opening duplicates.

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -6,11 +5,7 @@ using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher;
@@ -57,7 +52,7 @@ public partial class App : Application
}
else
{
var splashWindow = CreateSplashWindow();
var splashWindow = new SplashWindow();
splashWindow.Show();
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
}
@@ -73,7 +68,7 @@ public partial class App : Application
case "preview-splash":
{
Logger.Info("Preview command: splash.");
var splashWindow = CreateSplashWindow();
var splashWindow = new SplashWindow();
splashWindow.SetDebugMode(true);
splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
@@ -117,28 +112,6 @@ 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" };
@@ -199,330 +172,53 @@ 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);
if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource,
successPolicy,
coordinatorPipeName,
out var reservedAttempt,
out var activeCoordinatorAttempt))
try
{
result = await AttachToExistingCoordinatorAsync(
var appRoot = Commands.ResolveAppRoot(context);
Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
var deploymentLocator = new DeploymentLocator(appRoot);
var coordinator = new LauncherFlowCoordinator(
context,
currentSplashWindow,
activeCoordinatorAttempt).ConfigureAwait(false);
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
new PluginInstallerService());
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
return;
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
}
using var coordinatorServer = new LauncherCoordinatorIpcServer(
coordinatorPipeName,
BuildCoordinatorStatusFromAttempt(reservedAttempt),
HandleCoordinatorRequestAsync,
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
coordinatorServer.Start();
while (true)
catch (Exception ex)
{
try
Logger.Error("Coordinator threw an unhandled exception.", ex);
result = new LauncherResult
{
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();
Success = false,
Stage = "launch",
Code = "exception",
Message = $"Launcher failed: {ex.Message}",
ErrorMessage = ex.ToString()
};
}
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
if (!result.Success &&
result.Code is not "host_not_found" &&
(string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) ||
string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
{
await ShowFailureWindowAsync(result).ConfigureAwait(false);
}
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
private static async Task<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");
@@ -542,31 +238,15 @@ public partial class App : Application
}
}
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
private static async Task 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();
@@ -579,76 +259,16 @@ public partial class App : Application
if (errorWindow is null)
{
return ErrorWindowResult.Exit;
return;
}
try
{
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
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;
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -21,13 +20,6 @@ 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))]

View File

@@ -121,7 +121,6 @@ internal sealed class CommandContext
return raw.Trim().ToLowerInvariant() switch
{
"normal" => "normal",
"restart" => "restart",
"postinstall" => "postinstall",
"apply-update" => "apply-update",
"plugin-install" => "plugin-install",
@@ -147,13 +146,6 @@ internal sealed class CommandContext
continue;
}
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++i];

View File

@@ -8,7 +8,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.0-dev</Version>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- 应用程序图标 -->
@@ -18,7 +18,6 @@
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
<ItemGroup>
@@ -35,7 +34,6 @@
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
<!-- Avalonia 资源文件 -->
<AvaloniaResource Include="Assets\logo.ico" />
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">

View File

@@ -1,96 +0,0 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Models;
internal static class LauncherCoordinatorCommands
{
public const string Attach = "attach";
public const string ActivateDesktop = "activate-desktop";
public const string GetStatus = "get-status";
}
internal sealed class LauncherCoordinatorRequest
{
[JsonPropertyName("requestId")]
public string RequestId { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("command")]
public string Command { get; init; } = LauncherCoordinatorCommands.Attach;
[JsonPropertyName("launcherPid")]
public int LauncherPid { get; init; } = Environment.ProcessId;
[JsonPropertyName("launchSource")]
public string LaunchSource { get; init; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; init; } = string.Empty;
}
internal sealed class LauncherCoordinatorResponse
{
[JsonPropertyName("accepted")]
public bool Accepted { get; init; }
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("status")]
public LauncherCoordinatorStatus? Status { get; init; }
[JsonPropertyName("activationResult")]
public PublicShellActivationResult? ActivationResult { get; init; }
}
internal sealed class LauncherCoordinatorStatus
{
[JsonPropertyName("attemptId")]
public string AttemptId { get; init; } = string.Empty;
[JsonPropertyName("coordinatorPid")]
public int CoordinatorPid { get; init; } = Environment.ProcessId;
[JsonPropertyName("hostPid")]
public int HostPid { get; init; }
[JsonPropertyName("hostProcessAlive")]
public bool HostProcessAlive { get; init; }
[JsonPropertyName("launchSource")]
public string LaunchSource { get; init; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; init; } = string.Empty;
[JsonPropertyName("lastObservedStage")]
public StartupStage LastObservedStage { get; init; } = StartupStage.Initializing;
[JsonPropertyName("lastObservedMessage")]
public string LastObservedMessage { get; init; } = string.Empty;
[JsonPropertyName("publicIpcConnected")]
public bool PublicIpcConnected { get; init; }
[JsonPropertyName("state")]
public string State { get; init; } = string.Empty;
[JsonPropertyName("softTimeoutShown")]
public bool SoftTimeoutShown { get; init; }
[JsonPropertyName("completed")]
public bool Completed { get; init; }
[JsonPropertyName("succeeded")]
public bool Succeeded { get; init; }
[JsonPropertyName("shellStatus")]
public PublicShellStatus? ShellStatus { get; init; }
[JsonPropertyName("updatedAtUtc")]
public DateTimeOffset UpdatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -1,65 +0,0 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Models;
internal enum StartupAttemptState
{
Pending,
SoftTimeout,
DetachedWaiting,
Succeeded,
Failed,
WaitingForShell
}
internal sealed class StartupAttemptRecord
{
[JsonPropertyName("attemptId")]
public string AttemptId { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("hostPid")]
public int HostPid { get; set; }
[JsonPropertyName("coordinatorPid")]
public int CoordinatorPid { get; set; }
[JsonPropertyName("coordinatorPipeName")]
public string CoordinatorPipeName { get; set; } = string.Empty;
[JsonPropertyName("startedAtUtc")]
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
[JsonPropertyName("updatedAtUtc")]
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
[JsonPropertyName("heartbeatAtUtc")]
public DateTimeOffset HeartbeatAtUtc { get; set; } = DateTimeOffset.UtcNow;
[JsonPropertyName("launchSource")]
public string LaunchSource { get; set; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; set; } = string.Empty;
[JsonPropertyName("lastObservedStage")]
public StartupStage LastObservedStage { get; set; } = StartupStage.Initializing;
[JsonPropertyName("lastObservedMessage")]
public string LastObservedMessage { get; set; } = string.Empty;
[JsonPropertyName("ipcConnected")]
public bool IpcConnected { get; set; }
[JsonPropertyName("publicIpcConnected")]
public bool PublicIpcConnected { get; set; }
[JsonPropertyName("shellStatus")]
public string ShellStatus { get; set; } = string.Empty;
[JsonPropertyName("reservedBeforeHostStart")]
public bool ReservedBeforeHostStart { get; set; }
[JsonPropertyName("state")]
public StartupAttemptState State { get; set; } = StartupAttemptState.Pending;
}

View File

@@ -166,10 +166,7 @@ internal static class Commands
return Path.GetFullPath(configured);
}
var launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir)
? launcherDir
: AppContext.BaseDirectory);
var baseDir = AppContext.BaseDirectory;
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)

View File

@@ -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 鏍囪鐨勬帓鍓嶉潰
.ThenByDescending(x => x.Version) // 鐒跺悗鎸夌増鏈彿闄嶅簭
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 标记的æŽå‰<EFBFBD>é<EFBFBD>¢
.ThenByDescending(x => x.Version) // ç„¶å<EFBFBD>ŽæŒ‰ç‰ˆæœ¬å<EFBFBD>·é™<EFBFBD>åº<EFBFBD>
.ToList();
if (validInstallations.Count == 0)
@@ -204,16 +204,12 @@ internal sealed class DeploymentLocator
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
var fullSavedPath = Path.GetFullPath(savedCustomPath);
searchedPaths.Add(fullSavedPath);
if (File.Exists(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}'.");
source = "debug_saved_custom_path";
return fullSavedPath;
}
}
}
@@ -233,21 +229,6 @@ 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,
@@ -294,7 +275,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))
{
@@ -318,21 +299,13 @@ internal sealed class DeploymentLocator
return inParent;
}
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
// 4. å¼€å<EFBFBD>模å¼<EFBFBD>ï¼šå¦æžœå<EFBFBD>¯ç”¨äº†å¼€å<EFBFBD>模å¼<EFBFBD>,优先使用ä¿<EFBFBD>存的自定义路径
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
{
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
File.Exists(fullSavedPath))
{
return fullSavedPath;
}
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
{
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
return savedCustomPath;
}
var devPath = ScanDevelopmentPaths(executable);
@@ -342,7 +315,7 @@ internal sealed class DeploymentLocator
}
}
// 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
// 5. å¼€å<EFBFBD>模å¼<EFBFBD>:查找主ç¨åº<EFBFBD>项ç®çš„输出ç®å½•
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
@@ -356,21 +329,21 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
/// 扫æ<EFBFBD><EFBFBD>å¼€å<EFBFBD>路径(开å<EFBFBD>模å¼<EFBFBD>)
/// </summary>
private static string? ScanDevelopmentPaths(string executable)
{
var possiblePaths = new[]
{
// ?Launcher 椤圭洰杩愯
// ä»?Launcher 项ç®è¿<EFBFBD>行
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),
};
@@ -386,22 +359,22 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
/// 获å<EFBFBD>å¼€å<EFBFBD>环境å<EFBFBD>¯èƒ½çš„主ç¨åº<EFBFBD>è·¯å¾? /// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable)
{
var launcherDir = AppContext.BaseDirectory;
var possiblePaths = new[]
{
// ?Launcher 椤圭洰杩愯锛?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
// ä»?Launcher 项ç®è¿<EFBFBD>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯锛歀anMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
// ä»Žè§£å†³æ¹æ¡ˆæ ¹ç®å½•è¿<EFBFBD>行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// ?dev-test 鐩綍杩愯
// ä»?dev-test ç®å½•è¿<EFBFBD>行
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
};
@@ -436,8 +409,8 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? /// </summary>
/// <param name="minVersionsToKeep">鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
/// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最è¿çš„N个版æœ? /// </summary>
/// <param name="minVersionsToKeep">最å°ä¿<EFBFBD>留版本数,默è®?ä¸?/param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
@@ -465,10 +438,10 @@ internal sealed class DeploymentLocator
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
// 纭畾瑕佷繚鐣欑殑鐗堟湰
// 确定è¦<EFBFBD>ä¿<EFBFBD>留的版本
var versionsToKeep = new HashSet<string>();
// 1. 鎬绘槸淇濈暀褰撳墠鐗堟湰
// 1. 总是ä¿<EFBFBD>留当å‰<EFBFBD>版本
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion != null)
{
@@ -476,7 +449,7 @@ internal sealed class DeploymentLocator
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
}
// 2. 淇濈暀鏈€杩戠殑N涓湁鏁堢増鏈紙涓嶅寘鎷凡鏍囪destroy鐨勶級
// 2. ä¿<EFBFBD>留最è¿çš„N个有效版本(ä¸<EFBFBD>包æ¬å·²æ ‡è®°destroy的)
var activeVersions = validDeployments
.Where(d => !d.IsDestroyed)
.Take(minVersionsToKeep)
@@ -488,7 +461,7 @@ internal sealed class DeploymentLocator
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
}
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
// 3. ä¿<EFBFBD>ç•™æœ‰å¿«ç…§çš„ç‰ˆæœ¬ï¼ˆç”¨äºŽåžæ»šï¼‰
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
if (Directory.Exists(snapshotDir))
{
@@ -512,17 +485,17 @@ internal sealed class DeploymentLocator
}
catch
{
// 蹇界暐蹇収瑙f瀽閿欒
// 忽略快照解æž<EFBFBD>错误
}
}
}
catch
{
// 蹇界暐蹇収鐩綍璁块棶閿欒
// 忽略快照目录访问错误
}
}
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
// 清ç<EFBFBD>†ä¸<EFBFBD>需è¦<EFBFBD>的版本
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
@@ -536,7 +509,7 @@ internal sealed class DeploymentLocator
}
catch
{
// 蹇界暐鍙栨秷鏍囪澶辫触
// 忽略å<EFBFBD>消标记失败
}
}
continue;
@@ -551,11 +524,11 @@ internal sealed class DeploymentLocator
}
catch
{
// 蹇界暐鏍囪澶辫触
// 忽略标记失败
}
}
// 灏濊瘯鍒犻櫎
// å°<EFBFBD>试删除
try
{
Directory.Delete(deployment.Path, recursive: true);
@@ -563,7 +536,7 @@ internal sealed class DeploymentLocator
}
catch
{
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
// 忽略删除失败(å<>¯èƒ½æ‡ä»¶è¢«å<C2AB> ç”?,䏿¬¡å<C2A1>¯åЍå†<C3A5>试
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
}
}
@@ -571,12 +544,12 @@ internal sealed class DeploymentLocator
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
// 蹇界暐娓呯悊澶辫触
// 忽略清ç<EFBFBD>†å¤±è´¥
}
}
/// <summary>
/// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
/// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroyçš„éƒ¨ç½²ï¼ˆå…¼å®¹æ—§æ¹æ³•)
/// </summary>
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
@@ -608,17 +581,36 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </summary>
/// 从部署ç®å½•读å<EFBFBD>版本信æ<EFBFBD>? /// </summary>
public AppVersionInfo GetVersionInfo()
{
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var resolved = AppVersionProvider.ResolveFromPackageRoot(_appRoot, executableName);
return string.IsNullOrWhiteSpace(resolved.Version)
? new AppVersionInfo
var deploymentDir = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var versionFile = Path.Combine(deploymentDir, "version.json");
if (File.Exists(versionFile))
{
Version = GetCurrentVersion(),
Codename = "Administrate"
try
{
var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
if (info is not null)
{
return info;
}
}
catch
{
}
}
: resolved;
}
}
return new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate"
};
}
}

View File

@@ -560,11 +560,6 @@ namespace LanMountainDesktop.Launcher.Services;
}
}
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
}
return null;
}

View File

@@ -1,199 +0,0 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed record HostLaunchPlan(
string HostPath,
string PackageRoot,
string WorkingDirectory,
IReadOnlyList<string> Arguments,
IReadOnlyDictionary<string, string> EnvironmentVariables,
AppVersionInfo VersionInfo);
internal static class HostLaunchPlanBuilder
{
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
"app-root",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
public static HostLaunchPlan Build(
CommandContext context,
DeploymentLocator deploymentLocator,
HostResolutionResult resolution)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(deploymentLocator);
ArgumentNullException.ThrowIfNull(resolution);
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
{
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
}
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
var versionInfo = deploymentLocator.GetVersionInfo();
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo);
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
};
return new HostLaunchPlan(
hostPath,
packageRoot,
Directory.Exists(packageRoot)
? packageRoot
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
arguments,
environment,
versionInfo);
}
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
{
return string.Join(" ", arguments.Select(QuoteArgument));
}
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
{
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
? AppContext.BaseDirectory
: Path.GetFullPath(appRoot);
var hostDirectory = Path.GetDirectoryName(hostPath);
if (hostDirectory is not null &&
Directory.Exists(fullAppRoot) &&
IsAppDeploymentDirectory(hostDirectory) &&
IsParentOf(fullAppRoot, hostDirectory))
{
return fullAppRoot;
}
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
{
return fullAppRoot;
}
return hostDirectory ?? fullAppRoot;
}
private static IReadOnlyList<string> BuildForwardedArguments(
CommandContext context,
string packageRoot,
AppVersionInfo versionInfo)
{
var arguments = new List<string>();
for (var index = 0; index < context.RawArgs.Count; index++)
{
var arg = context.RawArgs[index];
if (index == 0 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (index == 1 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (arg.StartsWith("--", StringComparison.Ordinal))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
key = key[..equalsIndex];
}
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
{
if (equalsIndex < 0 &&
index + 1 < context.RawArgs.Count &&
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
{
index++;
}
continue;
}
}
arguments.Add(arg);
}
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
return arguments;
}
private static bool IsAppDeploymentDirectory(string path)
{
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
}
private static bool IsParentOf(string parent, string child)
{
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return childPath.StartsWith(
parentPath + Path.DirectorySeparatorChar,
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
}

View File

@@ -1,111 +0,0 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcClient
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
public async Task<LauncherCoordinatorResponse?> SendAsync(
string pipeName,
LauncherCoordinatorRequest request,
TimeSpan timeout)
{
if (string.IsNullOrWhiteSpace(pipeName))
{
return null;
}
using var timeoutCts = new CancellationTokenSource(timeout);
try
{
await using var client = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
await WriteRequestAsync(client, request, timeoutCts.Token).ConfigureAwait(false);
return await ReadResponseAsync(client, timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (TimeoutException)
{
return null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to send launcher coordinator IPC request: {ex.Message}");
return null;
}
}
private static async Task WriteRequestAsync(
Stream stream,
LauncherCoordinatorRequest request,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.LauncherCoordinatorRequest);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<LauncherCoordinatorResponse?> ReadResponseAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorResponse);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -1,235 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.IO.Pipes;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcServer : IDisposable
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
private readonly string _pipeName;
private readonly Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> _requestHandler;
private readonly Action<LauncherCoordinatorStatus> _heartbeatHandler;
private readonly CancellationTokenSource _cts = new();
private readonly object _statusGate = new();
private LauncherCoordinatorStatus _status;
private Task? _listenTask;
private Task? _heartbeatTask;
public LauncherCoordinatorIpcServer(
string pipeName,
LauncherCoordinatorStatus initialStatus,
Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> requestHandler,
Action<LauncherCoordinatorStatus> heartbeatHandler)
{
_pipeName = pipeName;
_status = initialStatus;
_requestHandler = requestHandler;
_heartbeatHandler = heartbeatHandler;
}
public static string CreatePipeName()
{
var seed = $"{Environment.UserName}:{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed.ToLowerInvariant()));
return $"LanMountainDesktop_Launcher_Coordinator_{Convert.ToHexString(bytes[..8])}";
}
public void Start()
{
_listenTask ??= Task.Run(ListenLoopAsync);
_heartbeatTask ??= Task.Run(HeartbeatLoopAsync);
}
public LauncherCoordinatorStatus GetStatus()
{
lock (_statusGate)
{
return _status;
}
}
public void UpdateStatus(LauncherCoordinatorStatus status)
{
lock (_statusGate)
{
_status = status;
}
}
public void Dispose()
{
_cts.Cancel();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(1));
_heartbeatTask?.Wait(TimeSpan.FromSeconds(1));
}
catch
{
}
_cts.Dispose();
}
private async Task ListenLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
NamedPipeServerStream? server = null;
try
{
server = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
8,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
var connectedServer = server;
_ = Task.Run(() => HandleConnectionAsync(connectedServer, _cts.Token), _cts.Token);
server = null;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator IPC listener failed: {ex.Message}");
try
{
await Task.Delay(250, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
server?.Dispose();
}
}
}
private async Task HeartbeatLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
try
{
_heartbeatHandler(GetStatus());
await Task.Delay(TimeSpan.FromSeconds(2), _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator heartbeat failed: {ex.Message}");
}
}
}
private async Task HandleConnectionAsync(NamedPipeServerStream server, CancellationToken cancellationToken)
{
try
{
var request = await ReadRequestAsync(server, cancellationToken).ConfigureAwait(false);
var status = GetStatus();
var response = request is null
? new LauncherCoordinatorResponse
{
Accepted = false,
Code = "invalid_request",
Message = "Launcher coordinator request was invalid.",
Status = status
}
: await _requestHandler(request, status).ConfigureAwait(false);
await WriteResponseAsync(server, response, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator IPC request failed: {ex.Message}");
}
finally
{
try
{
server.Dispose();
}
catch
{
}
}
}
private static async Task<LauncherCoordinatorRequest?> ReadRequestAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorRequest);
}
private static async Task WriteResponseAsync(
Stream stream,
LauncherCoordinatorResponse response,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, AppJsonContext.Default.LauncherCoordinatorResponse);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -1,124 +0,0 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
internal static class LauncherDebugSettingsStore
{
private const string DevModeFileName = "dev-mode.flag";
private const string CustomHostPathFileName = "custom-host-path.txt";
private const string LegacyDevModeFileName = "devmode.config";
private const string LegacyCustomHostPathFileName = "custom-host-path.config";
internal static string? ConfigBaseDirectoryOverride { get; set; }
public static string ConfigBaseDirectory => ConfigBaseDirectoryOverride ?? ResolveConfigBaseDirectory();
public static LauncherDebugSettings Load()
{
return new LauncherDebugSettings(
LoadDevModeState(),
LoadCustomHostPath());
}
public static bool IsDevModeEnabled() => Load().DevModeEnabled;
public static string? GetSavedCustomHostPath() => Load().CustomHostPath;
public static void Save(LauncherDebugSettings settings)
{
try
{
Directory.CreateDirectory(ConfigBaseDirectory);
File.WriteAllText(GetPath(DevModeFileName), settings.DevModeEnabled.ToString());
File.WriteAllText(GetPath(CustomHostPathFileName), settings.CustomHostPath ?? string.Empty);
}
catch (Exception ex)
{
Logger.Warn($"Failed to save launcher debug settings: {ex.Message}");
}
}
public static void SaveDevModeState(bool enabled)
{
var current = Load();
Save(current with { DevModeEnabled = enabled });
}
public static void SaveCustomHostPath(string? customHostPath)
{
var current = Load();
Save(current with { CustomHostPath = customHostPath });
}
private static bool LoadDevModeState()
{
var newValue = TryReadText(GetPath(DevModeFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return TryParseDevMode(newValue);
}
var legacyValue = TryReadText(GetPath(LegacyDevModeFileName));
return !string.IsNullOrWhiteSpace(legacyValue) && TryParseDevMode(legacyValue);
}
private static string? LoadCustomHostPath()
{
var newValue = TryReadText(GetPath(CustomHostPathFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return newValue.Trim();
}
var legacyValue = TryReadText(GetPath(LegacyCustomHostPathFileName));
return string.IsNullOrWhiteSpace(legacyValue) ? null : legacyValue.Trim();
}
private static bool TryParseDevMode(string value)
{
var normalized = value.Trim();
return normalized == "1" ||
normalized.Equals("true", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("on", StringComparison.OrdinalIgnoreCase);
}
private static string? TryReadText(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to read launcher debug setting '{path}': {ex.Message}");
return null;
}
}
private static string GetPath(string fileName) => Path.Combine(ConfigBaseDirectory, fileName);
private static string ResolveConfigBaseDirectory()
{
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
}
}
catch
{
}
try
{
return Path.Combine(AppContext.BaseDirectory, ".launcher");
}
catch
{
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,540 +0,0 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class StartupAttemptRegistry
{
private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10);
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private readonly string _statePath;
private readonly string _mutexName;
private string? _ownedAttemptId;
public StartupAttemptRegistry()
: this(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
".launcher",
"state",
"startup-attempt.json"))
{
}
internal StartupAttemptRegistry(string statePath)
{
_statePath = statePath;
_mutexName = $"LanMountainDesktop.Launcher.StartupAttempt.{ComputePathHash(statePath)}";
}
public StartupAttemptRecord StartOwnedAttempt(
int hostPid,
string launchSource,
string successPolicy,
StartupStage stage,
string? message)
{
var record = new StartupAttemptRecord
{
AttemptId = Guid.NewGuid().ToString("N"),
HostPid = hostPid,
CoordinatorPid = Environment.ProcessId,
LaunchSource = launchSource,
SuccessPolicy = successPolicy,
LastObservedStage = stage,
LastObservedMessage = message ?? string.Empty,
StartedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
HeartbeatAtUtc = DateTimeOffset.UtcNow,
State = StartupAttemptState.Pending
};
ExecuteWithLock(() =>
{
SaveUnsafe(record);
_ownedAttemptId = record.AttemptId;
});
return Clone(record);
}
public bool TryReserveCoordinator(
string launchSource,
string successPolicy,
string coordinatorPipeName,
out StartupAttemptRecord reservedAttempt,
out StartupAttemptRecord? activeCoordinatorAttempt)
{
StartupAttemptRecord? reserved = null;
StartupAttemptRecord? active = null;
ExecuteWithLock(() =>
{
var existing = LoadUnsafe();
if (existing is not null && IsCoordinatorLive(existing))
{
active = Clone(existing);
return;
}
if (existing is not null && IsRecoverableCoordinatorAttempt(existing))
{
existing.CoordinatorPid = Environment.ProcessId;
existing.CoordinatorPipeName = coordinatorPipeName;
existing.HeartbeatAtUtc = DateTimeOffset.UtcNow;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
if (existing.HostPid <= 0)
{
existing.ReservedBeforeHostStart = true;
}
if (existing.State == StartupAttemptState.DetachedWaiting)
{
existing.State = StartupAttemptState.SoftTimeout;
}
_ownedAttemptId = existing.AttemptId;
SaveUnsafe(existing);
reserved = Clone(existing);
return;
}
var now = DateTimeOffset.UtcNow;
var record = new StartupAttemptRecord
{
AttemptId = Guid.NewGuid().ToString("N"),
HostPid = 0,
CoordinatorPid = Environment.ProcessId,
CoordinatorPipeName = coordinatorPipeName,
LaunchSource = launchSource,
SuccessPolicy = successPolicy,
LastObservedStage = StartupStage.Initializing,
LastObservedMessage = "Launcher coordinator reserved startup ownership.",
StartedAtUtc = now,
UpdatedAtUtc = now,
HeartbeatAtUtc = now,
ReservedBeforeHostStart = true,
State = StartupAttemptState.Pending
};
_ownedAttemptId = record.AttemptId;
SaveUnsafe(record);
reserved = Clone(record);
});
reservedAttempt = reserved ?? new StartupAttemptRecord();
activeCoordinatorAttempt = active;
return reserved is not null;
}
public StartupAttemptRecord? GetOwnedAttempt()
{
StartupAttemptRecord? result = null;
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
{
return null;
}
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is not null && string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
{
result = Clone(record);
}
});
return result;
}
public StartupAttemptRecord? TryGetLiveCoordinatorAttempt()
{
StartupAttemptRecord? result = null;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is not null && IsCoordinatorLive(record))
{
result = Clone(record);
}
});
return result;
}
public StartupAttemptRecord? TryGetLatestAttempt()
{
StartupAttemptRecord? result = null;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is not null)
{
result = Clone(record);
}
});
return result;
}
public StartupAttemptRecord AssignOwnedHostProcess(
int hostPid,
StartupStage stage,
string? message)
{
StartupAttemptRecord? result = null;
UpdateOwned(record =>
{
record.HostPid = hostPid;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
record.ReservedBeforeHostStart = false;
result = Clone(record);
});
return result ?? StartOwnedAttempt(
hostPid,
string.Empty,
string.Empty,
stage,
message);
}
public bool AdoptAttempt(string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId))
{
return false;
}
var adopted = false;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is null || !string.Equals(record.AttemptId, attemptId, StringComparison.Ordinal))
{
return;
}
if (!IsAttachable(record))
{
return;
}
_ownedAttemptId = record.AttemptId;
if (record.State == StartupAttemptState.DetachedWaiting)
{
record.State = StartupAttemptState.SoftTimeout;
}
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
SaveUnsafe(record);
adopted = true;
});
return adopted;
}
public StartupAttemptRecord? TryGetAttachableAttempt(string launchSource, string successPolicy)
{
StartupAttemptRecord? result = null;
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is null ||
!IsAttachable(record) ||
!string.Equals(record.LaunchSource, launchSource, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(record.SuccessPolicy, successPolicy, StringComparison.OrdinalIgnoreCase))
{
return;
}
result = Clone(record);
});
return result;
}
public void MarkOwnedIpcConnected()
{
UpdateOwned(record =>
{
record.IpcConnected = true;
record.PublicIpcConnected = true;
});
}
public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected)
{
UpdateOwned(record =>
{
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? string.Empty;
if (ipcConnected)
{
record.IpcConnected = true;
record.PublicIpcConnected = true;
}
});
}
public void UpdateOwnedCoordinatorHeartbeat(LauncherCoordinatorStatus status)
{
UpdateOwned(record =>
{
record.CoordinatorPid = Environment.ProcessId;
record.HeartbeatAtUtc = DateTimeOffset.UtcNow;
record.LastObservedStage = status.LastObservedStage;
record.LastObservedMessage = status.LastObservedMessage;
record.IpcConnected = status.PublicIpcConnected;
record.PublicIpcConnected = status.PublicIpcConnected;
record.ShellStatus = status.ShellStatus?.ShellState ?? status.State;
});
}
public void MarkOwnedSoftTimeout(string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.SoftTimeout;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedWaitingForShell(string? message)
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
{
record.State = StartupAttemptState.WaitingForShell;
}
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedDetachedWaiting()
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout)
{
record.State = StartupAttemptState.DetachedWaiting;
}
});
}
public void MarkOwnedSucceeded(StartupStage stage, string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.Succeeded;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedFailed(StartupStage stage, string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.Failed;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
private void UpdateOwned(Action<StartupAttemptRecord> update)
{
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
{
return;
}
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
{
return;
}
update(record);
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
SaveUnsafe(record);
});
}
private void ExecuteWithLock(Action action)
{
using var mutex = new Mutex(false, _mutexName);
var hasHandle = false;
try
{
try
{
hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2));
}
catch (AbandonedMutexException)
{
hasHandle = true;
}
if (!hasHandle)
{
return;
}
action();
}
finally
{
if (hasHandle)
{
mutex.ReleaseMutex();
}
}
}
private StartupAttemptRecord? LoadUnsafe()
{
if (!File.Exists(_statePath))
{
return null;
}
try
{
var json = File.ReadAllText(_statePath);
return JsonSerializer.Deserialize<StartupAttemptRecord>(json, SerializerOptions);
}
catch
{
return null;
}
}
private void SaveUnsafe(StartupAttemptRecord record)
{
var directory = Path.GetDirectoryName(_statePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions));
}
private static bool IsAttachable(StartupAttemptRecord record)
{
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
return TryGetLiveProcess(record.HostPid, out _);
}
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
{
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
if (record.HostPid <= 0)
{
return true;
}
return TryGetLiveProcess(record.HostPid, out _);
}
private static bool IsCoordinatorLive(StartupAttemptRecord record)
{
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
if (record.CoordinatorPid <= 0 ||
string.IsNullOrWhiteSpace(record.CoordinatorPipeName) ||
DateTimeOffset.UtcNow - record.HeartbeatAtUtc > CoordinatorHeartbeatTimeout)
{
return false;
}
return TryGetLiveProcess(record.CoordinatorPid, out _);
}
private static bool TryGetLiveProcess(int processId, out Process? process)
{
process = null;
if (processId <= 0)
{
return false;
}
try
{
process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
process?.Dispose();
process = null;
return false;
}
}
private static string ComputePathHash(string statePath)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(statePath.ToLowerInvariant()));
return Convert.ToHexString(bytes[..8]);
}
private static StartupAttemptRecord Clone(StartupAttemptRecord record)
{
return new StartupAttemptRecord
{
AttemptId = record.AttemptId,
HostPid = record.HostPid,
CoordinatorPid = record.CoordinatorPid,
CoordinatorPipeName = record.CoordinatorPipeName,
StartedAtUtc = record.StartedAtUtc,
UpdatedAtUtc = record.UpdatedAtUtc,
HeartbeatAtUtc = record.HeartbeatAtUtc,
LaunchSource = record.LaunchSource,
SuccessPolicy = record.SuccessPolicy,
LastObservedStage = record.LastObservedStage,
LastObservedMessage = record.LastObservedMessage,
IpcConnected = record.IpcConnected,
PublicIpcConnected = record.PublicIpcConnected,
ShellStatus = record.ShellStatus,
ReservedBeforeHostStart = record.ReservedBeforeHostStart,
State = record.State
};
}
}

View File

@@ -5,41 +5,52 @@ using Avalonia.Platform.Storage;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误调试窗口 - 开发人员专用调试设置
/// </summary>
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
private bool _isInitialized;
private bool _isInitialized = false;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled { get; private set; }
public bool WasAccepted { get; private set; }
/// <summary>
/// 选择的主程序路径
/// </summary>
public string? SelectedHostPath => _selectedHostPath;
public ErrorDebugWindow()
{
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
// 延迟到窗口加载完成后再初始化组件
this.Loaded += OnWindowLoaded;
}
public ErrorDebugWindow(bool devModeEnabled, string? initialPath)
: this()
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
{
IsDevModeEnabled = devModeEnabled;
_selectedHostPath = initialPath;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
if (_isInitialized)
{
return;
}
if (_isInitialized) return;
_isInitialized = true;
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
InitializeComponents();
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
// 设置初始值(在视觉树准备好后)
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
{
devModeToggle.IsChecked = IsDevModeEnabled;
}
@@ -49,72 +60,113 @@ public partial class ErrorDebugWindow : Window
private void InitializeComponents()
{
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
// 开发模式开关
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
{
devModeToggle.IsCheckedChanged += (_, _) =>
devModeToggle.IsCheckedChanged += (s, e) =>
{
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
};
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
}
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
// 浏览按钮
var browseButton = this.FindControl<Button>("BrowseButton");
if (browseButton is not null)
{
browseButton.Click += OnBrowseClick;
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
}
if (this.FindControl<Button>("OkButton") is { } okButton)
// 确定按钮
var okButton = this.FindControl<Button>("OkButton");
if (okButton is not null)
{
okButton.Click += (_, _) =>
okButton.Click += (s, e) => Close();
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
}
// 取消按钮
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton is not null)
{
cancelButton.Click += (s, e) =>
{
WasAccepted = true;
// 取消时恢复原始状态
IsDevModeEnabled = false;
_selectedHostPath = null;
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
Close();
};
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
}
if (this.FindControl<Button>("CancelButton") is { } cancelButton)
else
{
cancelButton.Click += (_, _) => Close();
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
}
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
}
/// <summary>
/// 浏览按钮点击
/// </summary>
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var storageProvider = StorageProvider;
if (storageProvider is null)
{
return;
}
if (storageProvider is null) return;
var options = new FilePickerOpenOptions
{
Title = "Select LanMountainDesktop host executable",
Title = "选择阑山桌面主程序",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Executable")
FileTypeFilter = new[]
{
new FilePickerFileType("可执行文件")
{
Patterns = OperatingSystem.IsWindows()
? ["*.exe"]
: ["*"]
? new[] { "*.exe" }
: new[] { "*" }
}
]
}
};
var result = await storageProvider.OpenFilePickerAsync(options);
if (result.Count <= 0)
if (result.Count > 0)
{
return;
_selectedHostPath = result[0].Path.LocalPath;
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
UpdatePathDisplay(_selectedHostPath);
}
_selectedHostPath = result[0].Path.LocalPath;
UpdatePathDisplay(_selectedHostPath);
}
/// <summary>
/// 更新路径显示
/// </summary>
private void UpdatePathDisplay(string? path)
{
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
if (pathTextBlock is not null)
{
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
}
}
}

View File

@@ -3,96 +3,102 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow"
Title="LanMountain Desktop"
Width="560"
Height="320"
Title="阑山桌面"
Width="520"
Height="280"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="#111318"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorWindow />
</Design.DataContext>
<!-- Fluent Design 风格对话框布局 -->
<Grid RowDefinitions="*,Auto">
<Grid Grid.Row="0"
Margin="24"
ColumnDefinitions="Auto,*">
<!-- 主内容区域:左侧图标 + 右侧文字 -->
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
<!-- 左侧:错误图标(可点击进入调试模式) -->
<Border x:Name="ErrorIconBorder"
Grid.Column="0"
Width="52"
Height="52"
Margin="0,4,18,0"
Background="#2B161A"
CornerRadius="26"
Width="48"
Height="48"
Margin="0,4,16,0"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="24"
VerticalAlignment="Top">
<TextBlock Text="!"
<TextBlock Text="&#xEA39;"
FontSize="24"
FontWeight="Bold"
Foreground="#FFB4AB"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1"
Spacing="10">
<!-- 右侧:标题 + 内容 -->
<StackPanel Grid.Column="1" Spacing="8">
<!-- 标题 -->
<TextBlock x:Name="TitleText"
Text="Launcher could not confirm startup"
FontSize="20"
Text="启动失败"
FontSize="18"
FontWeight="SemiBold"
Foreground="#F6F7FB"
TextWrapping="Wrap" />
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"/>
<!-- 错误信息 -->
<TextBlock x:Name="ErrorMessageText"
Text="LanMountain Desktop did not reach the expected startup state."
Text="找不到阑山桌面应用程序。"
FontSize="14"
Foreground="#D2D7E1"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="22" />
LineHeight="20"/>
<!-- 建议信息 -->
<TextBlock x:Name="SuggestionText"
Text="You can inspect logs, retry when the old process is gone, or reactivate the current instance."
Text="请确保应用程序已正确安装,或尝试重新安装。"
FontSize="13"
Foreground="#9BA5B7"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
TextWrapping="Wrap"
LineHeight="20" />
LineHeight="18"
Margin="0,4,0,0"/>
</StackPanel>
</Grid>
<!-- 底部:按钮区域 -->
<Border Grid.Row="1"
Padding="24,16"
Background="#171A21">
<Grid ColumnDefinitions="*,Auto,Auto,Auto"
ColumnSpacing="8">
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="24,16">
<Grid ColumnDefinitions="*,Auto">
<Button x:Name="OpenLogButton"
Grid.Column="0"
Content="Open Logs"
MinWidth="108"
Height="34"
HorizontalAlignment="Left" />
<Button x:Name="SecondaryActionButton"
Grid.Column="1"
Content="Wait"
MinWidth="108"
Height="34"
IsVisible="False" />
<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" />
Content="打开日志"
Width="100"
Height="32"
FontSize="13"
HorizontalAlignment="Left"/>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="ExitButton"
Content="退出"
Width="80"
Height="32"
FontSize="13"/>
<Button x:Name="RetryButton"
Content="重试"
Width="80"
Height="32"
FontSize="13"
Theme="{DynamicResource AccentButtonTheme}"/>
</StackPanel>
</Grid>
</Border>
</Grid>

View File

@@ -1,314 +1,542 @@
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 readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _iconClickCount;
private bool _isDebugMode;
private bool _devModeEnabled;
private bool _isDebugMode = false;
private string? _customHostPath;
private ErrorWindowResult _primaryAction = ErrorWindowResult.Retry;
private ErrorWindowResult? _secondaryAction;
private bool _devModeEnabled;
public ErrorWindow()
{
AvaloniaXamlLoader.Load(this);
// 先加载保存的状态
_devModeEnabled = LoadDevModeStateInternal();
_customHostPath = LoadCustomHostPathInternal();
Loaded += OnWindowLoaded;
Closed += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
ConfigureForGenericFailure(allowRetry: true);
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
this.Loaded += OnWindowLoaded;
this.Opened += OnWindowOpened;
}
/// <summary>
/// 窗口加载完成事件 - 视觉树已准备好
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
InitializeComponents();
}
/// <summary>
/// 窗口打开事件
/// </summary>
private void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[ErrorWindow] Window opened and visible");
}
private void InitializeComponents()
{
Console.WriteLine("[ErrorWindow] Initializing components...");
// 错误图标点击事件(进入调试模式 - 隐藏功能)
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
if (errorIconBorder is not null)
{
errorIconBorder.PointerPressed += OnErrorIconClick;
Console.WriteLine("[ErrorWindow] ErrorIconBorder event bound successfully");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find ErrorIconBorder!");
}
// 按钮事件
var retryButton = this.FindControl<Button>("RetryButton");
var exitButton = this.FindControl<Button>("ExitButton");
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)
{
if (this.FindControl<TextBlock>("ErrorMessageText") is { } errorText)
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
if (errorText is not null)
{
errorText.Text = message;
}
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
_isDebugMode = isDebugMode;
if (isDebugMode && this.FindControl<TextBlock>("TitleText") is { } titleText)
var titleText = this.FindControl<TextBlock>("TitleText");
if (titleText is not null && isDebugMode)
{
titleText.Text = "[Debug] Launcher error";
titleText.Text = "[调试模式] 错误页面";
}
}
public void ConfigureForHostNotFound()
{
ApplyActionLayout(
title: "Launcher could not find the desktop executable",
suggestion: "Pick another executable in debug mode, inspect logs, or retry after fixing the deployment path.",
primaryLabel: "Retry",
primaryAction: ErrorWindowResult.Retry,
secondaryLabel: null,
secondaryAction: null);
}
public void ConfigureForGenericFailure(bool allowRetry)
{
ApplyActionLayout(
title: "Launcher could not confirm startup",
suggestion: allowRetry
? "Inspect logs, then retry once the previous startup attempt has fully finished."
: "Inspect logs or exit. Launcher will avoid creating another desktop process while the old one is still running.",
primaryLabel: allowRetry ? "Retry" : "Activate",
primaryAction: allowRetry ? ErrorWindowResult.Retry : ErrorWindowResult.ActivateExisting,
secondaryLabel: allowRetry ? null : "Wait",
secondaryAction: allowRetry ? null : ErrorWindowResult.ContinueWaiting);
}
public void ConfigureForRunningHostFailure(int? hostPid)
{
var pidHint = hostPid is > 0 ? $" Current host PID: {hostPid}." : string.Empty;
ApplyActionLayout(
title: "Startup is still pending",
suggestion: $"The desktop process is still running, so Launcher will not start a second instance.{pidHint}",
primaryLabel: "Activate",
primaryAction: ErrorWindowResult.ActivateExisting,
secondaryLabel: "Wait",
secondaryAction: ErrorWindowResult.ContinueWaiting);
}
/// <summary>
/// 获取用户选择的主程序路径
/// </summary>
public string? GetCustomHostPath() => _customHostPath;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled() => _devModeEnabled;
public Task<ErrorWindowResult> WaitForChoiceAsync() => _completionSource.Task;
public static bool CheckDevModeEnabled() => LoadDevModeStateInternal();
public static string? GetSavedCustomHostPath() => LoadCustomHostPathInternal();
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
/// <summary>
/// 等待用户选择
/// </summary>
public Task<ErrorWindowResult> WaitForChoiceAsync()
{
if (this.FindControl<Border>("ErrorIconBorder") is { } errorIconBorder)
{
errorIconBorder.PointerPressed += OnErrorIconClick;
}
if (this.FindControl<Button>("PrimaryActionButton") is { } primaryActionButton)
{
primaryActionButton.Click += OnPrimaryActionClick;
}
if (this.FindControl<Button>("SecondaryActionButton") is { } secondaryActionButton)
{
secondaryActionButton.Click += OnSecondaryActionClick;
}
if (this.FindControl<Button>("ExitButton") is { } exitButton)
{
exitButton.Click += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
}
if (this.FindControl<Button>("OpenLogButton") is { } openLogButton)
{
openLogButton.Click += OnOpenLogClick;
}
return _completionSource.Task;
}
private void ApplyActionLayout(
string title,
string suggestion,
string primaryLabel,
ErrorWindowResult primaryAction,
string? secondaryLabel,
ErrorWindowResult? secondaryAction)
{
_primaryAction = primaryAction;
_secondaryAction = secondaryAction;
if (this.FindControl<TextBlock>("TitleText") is { } titleText && !_isDebugMode)
{
titleText.Text = title;
}
if (this.FindControl<TextBlock>("SuggestionText") is { } suggestionText)
{
suggestionText.Text = suggestion;
}
if (this.FindControl<Button>("PrimaryActionButton") is { } primaryButton)
{
primaryButton.Content = primaryLabel;
}
if (this.FindControl<Button>("SecondaryActionButton") is { } secondaryButton)
{
secondaryButton.IsVisible = !string.IsNullOrWhiteSpace(secondaryLabel);
secondaryButton.Content = secondaryLabel ?? string.Empty;
}
}
private void OnPrimaryActionClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(_primaryAction);
}
private void OnSecondaryActionClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(_secondaryAction ?? ErrorWindowResult.Exit);
}
private void OnErrorIconClick(object? sender, PointerPressedEventArgs e)
/// <summary>
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
/// </summary>
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
_iconClickCount++;
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
{
EnterDebugMode();
}
}
/// <summary>
/// 进入调试模式 - 显示调试窗口
/// </summary>
private async void EnterDebugMode()
{
_isDebugMode = true;
// 创建并显示调试窗口
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
{
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
debugWindow.Closed += (_, _) =>
// 订阅调试窗口关闭事件
debugWindow.Closed += (s, e) =>
{
if (!debugWindow.WasAccepted)
{
_isDebugMode = false;
_iconClickCount = 0;
return;
}
// 更新状态
_devModeEnabled = debugWindow.IsDevModeEnabled;
_customHostPath = debugWindow.SelectedHostPath;
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
// 保存开发模式状态和自定义路径
SaveDevModeStateInternal(_devModeEnabled);
SaveCustomHostPathInternal(_customHostPath);
// 如果启用了开发模式且没有选择路径,自动扫描
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
{
ScanDevPaths();
// 扫描到路径后也保存
if (!string.IsNullOrEmpty(_customHostPath))
{
SaveCustomHostPathInternal(_customHostPath);
}
}
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _customHostPath));
_isDebugMode = false;
_iconClickCount = 0;
};
await debugWindow.ShowDialog(this);
}
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
{
try
{
var logFilePath = Logger.GetLogFilePath();
if (!string.IsNullOrWhiteSpace(logFilePath) && File.Exists(logFilePath))
{
OpenPath(logFilePath);
return;
}
var logDirectory = !string.IsNullOrWhiteSpace(logFilePath)
? Path.GetDirectoryName(logFilePath)
: null;
if (!string.IsNullOrWhiteSpace(logDirectory) && Directory.Exists(logDirectory))
{
OpenPath(logDirectory);
return;
}
var configDirectory = GetConfigBaseDirectory();
if (Directory.Exists(configDirectory))
{
OpenPath(configDirectory);
}
}
catch (Exception ex)
{
Debug.WriteLine($"[ErrorWindow] Failed to open log path: {ex}");
}
await Task.CompletedTask;
}
/// <summary>
/// 扫描开发路径
/// </summary>
private void ScanDevPaths()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var candidatePaths = new[]
var possiblePaths = new[]
{
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable)
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
foreach (var candidate in candidatePaths.Select(Path.GetFullPath).Distinct())
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(candidate))
if (File.Exists(path))
{
_customHostPath = candidate;
_customHostPath = path;
break;
}
}
}
private static void OpenPath(string path)
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{path}\"",
UseShellExecute = true
});
return;
}
if (OperatingSystem.IsMacOS())
{
Process.Start("open", path);
return;
}
if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", path);
}
}
/// <summary>
/// 获取配置存储的基础目录
/// </summary>
private static string GetConfigBaseDirectory()
{
return LauncherDebugSettingsStore.ConfigBaseDirectory;
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()
{
return LauncherDebugSettingsStore.IsDevModeEnabled();
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()
{
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
try
{
var configDir = GetConfigBaseDirectory();
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
if (File.Exists(hostPathFile))
{
var content = File.ReadAllText(hostPathFile).Trim();
// 验证路径是否仍然有效
if (!string.IsNullOrEmpty(content) && File.Exists(content))
{
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
return content;
}
// 路径已失效,清理配置文件
if (!string.IsNullOrEmpty(content))
{
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
try
{
File.Delete(hostPathFile);
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
}
catch (Exception clearEx)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
}
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
}
return null;
}
/// <summary>
/// 检查是否启用了开发模式(静态方法,启动时调用)
/// </summary>
public static bool CheckDevModeEnabled()
{
return LoadDevModeStateInternal();
}
/// <summary>
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
/// </summary>
public static string? GetSavedCustomHostPath()
{
return LoadCustomHostPathInternal();
}
private void OnRetryClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Retry);
}
private void OnExitClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Exit);
}
/// <summary>
/// 打开日志文件
/// </summary>
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
{
try
{
var logFilePath = Logger.GetLogFilePath();
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
{
// 如果没有日志文件,打开日志目录
var logDir = Path.GetDirectoryName(logFilePath);
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
{
OpenFolder(logDir);
}
else
{
// 尝试打开配置目录
var configDir = GetConfigBaseDirectory();
if (Directory.Exists(configDir))
{
OpenFolder(configDir);
}
else
{
Console.WriteLine("[ErrorWindow] No log file or directory available");
}
}
return;
}
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
OpenFile(logFilePath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
}
}
/// <summary>
/// 打开文件
/// </summary>
private static void OpenFile(string filePath)
{
try
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{filePath}\"",
UseShellExecute = true
});
}
else if (OperatingSystem.IsMacOS())
{
Process.Start("open", filePath);
}
else if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", filePath);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
}
}
/// <summary>
/// 打开文件夹
/// </summary>
private static void OpenFolder(string folderPath)
{
try
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{folderPath}\"",
UseShellExecute = true
});
}
else if (OperatingSystem.IsMacOS())
{
Process.Start("open", folderPath);
}
else if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", folderPath);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
}
}
}
/// <summary>
/// 错误窗口用户选择结果
/// </summary>
public enum ErrorWindowResult
{
/// <summary>
/// 重试
/// </summary>
Retry,
Exit,
ActivateExisting,
ContinueWaiting
/// <summary>
/// 退出
/// </summary>
Exit
}

View File

@@ -21,11 +21,7 @@
<views:OobeWindow />
</Design.DataContext>
<Grid x:Name="ContentGrid"
Opacity="0">
<Grid.RenderTransform>
<TranslateTransform Y="24" />
</Grid.RenderTransform>
<Grid x:Name="ContentGrid">
<!-- 主内容区域 -->
<Grid Margin="48" RowDefinitions="*,Auto">
<!-- 中央内容区域 -->

View File

@@ -9,18 +9,26 @@ using Avalonia.Styling;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// OOBE首次使用体验窗口 - 欢迎页面
/// </summary>
public partial class OobeWindow : Window
{
private readonly TaskCompletionSource<bool> _completionSource = new();
private bool _isTransitioning;
private bool _isTransitioning = false;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
// 延迟到窗口加载完成后再初始化
this.Loaded += OnWindowLoaded;
this.Opened += OnWindowOpened;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
@@ -37,29 +45,31 @@ public partial class OobeWindow : Window
}
}
/// <summary>
/// 窗口打开事件 - 播放入场动画
/// </summary>
private async void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
await PlayEntranceAnimationAsync();
}
/// <summary>
/// 播放入场动画
/// </summary>
private async Task PlayEntranceAnimationAsync()
{
try
{
// 获取内容元素
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接返回
return;
}
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
contentGrid.RenderTransform = translateTransform;
var offset = ResolveEntranceOffset();
contentGrid.Opacity = 0;
translateTransform.Y = offset;
// 创建淡入动画
var fadeInAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
@@ -79,6 +89,7 @@ public partial class OobeWindow : Window
}
};
// 创建向上滑动动画
var slideUpAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
@@ -87,7 +98,7 @@ public partial class OobeWindow : Window
{
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, offset) },
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
@@ -98,9 +109,9 @@ public partial class OobeWindow : Window
}
};
await Task.WhenAll(
fadeInAnimation.RunAsync(contentGrid),
slideUpAnimation.RunAsync(translateTransform));
// 应用动画
await fadeInAnimation.RunAsync(contentGrid);
await slideUpAnimation.RunAsync(contentGrid);
Console.WriteLine("[OobeWindow] Entrance animation completed");
}
@@ -110,21 +121,27 @@ public partial class OobeWindow : Window
}
}
/// <summary>
/// 等待用户点击开始按钮
/// </summary>
public Task WaitForEnterAsync() => _completionSource.Task;
/// <summary>
/// 进入按钮点击事件
/// </summary>
private async void OnEnterClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning)
{
return;
}
if (_isTransitioning) return;
_isTransitioning = true;
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
try
{
// 播放退出动画
await PlayExitAnimationAsync();
// 完成 OOBE
_completionSource.TrySetResult(true);
}
catch (Exception ex)
@@ -134,6 +151,9 @@ public partial class OobeWindow : Window
}
}
/// <summary>
/// 播放退出动画
/// </summary>
private async Task PlayExitAnimationAsync()
{
try
@@ -141,10 +161,12 @@ public partial class OobeWindow : Window
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接延迟后返回
await Task.Delay(200);
return;
}
// 创建淡出动画
var fadeOutAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(200),
@@ -172,11 +194,4 @@ public partial class OobeWindow : Window
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
}
}
private double ResolveEntranceOffset()
{
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
var scaledOffset = boundsHeight * 0.05;
return Math.Clamp(scaledOffset, 20, 48);
}
}

View File

@@ -3,92 +3,85 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="480"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
x:DataType="views:SplashWindow"
Title="LanMountain Desktop"
Width="480"
Height="320"
CanResize="False"
ShowInTaskbar="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="#0B0B0B"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:SplashWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto"
Background="#0B0B0B">
<Grid Grid.Row="0">
<Grid x:Name="CompactHero"
Margin="24">
<TextBlock x:Name="AppNameText"
Text="LanMountain Desktop"
FontSize="24"
FontWeight="SemiBold"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Foreground="#F6F7FB" />
</Grid>
<Grid>
<!-- 左上角:应用名称 -->
<TextBlock x:Name="AppNameText"
Text="LanMountain Desktop"
FontSize="24"
FontWeight="SemiBold"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Margin="24,24,0,0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<Grid x:Name="FullscreenHero"
IsVisible="False">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="24">
<Border Width="240"
Height="240"
Background="Transparent">
<Image Source="/Assets/logo_nightly.png"
Stretch="Uniform" />
</Border>
<TextBlock Text="LanMountain Desktop"
HorizontalAlignment="Center"
FontSize="26"
FontWeight="SemiBold"
Foreground="#F6F7FB" />
</StackPanel>
</Grid>
</Grid>
<Border Grid.Row="1"
Padding="24,18,24,24"
Background="Transparent">
<Grid RowDefinitions="Auto,Auto"
RowSpacing="10">
<Grid ColumnDefinitions="*,Auto">
<Border x:Name="VersionTextBorder"
Background="Transparent"
Cursor="Hand"
HorizontalAlignment="Left">
<TextBlock x:Name="VersionText"
FontSize="11"
Foreground="#B9C0CC"
Text="0.0.0-dev (Administrate)" />
</Border>
<TextBlock x:Name="StatusText"
Grid.Column="1"
<!-- 底部区域:进度条和状态 -->
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 第一行:左下角版本信息,右下角阶段文字 -->
<Grid Grid.Row="0" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) -->
<Border x:Name="VersionTextBorder"
Grid.Column="0"
Background="Transparent"
Cursor="Hand"
HorizontalAlignment="Left"
VerticalAlignment="Bottom">
<TextBlock x:Name="VersionText"
FontSize="11"
Foreground="#B9C0CC"
HorizontalAlignment="Right"
Text="Initializing..." />
</Grid>
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
IsIndeterminate="False"
Foreground="#F6F7FB"
Background="#2C313D" />
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
Text="1.0.0 (Administrate)" />
</Border>
<!-- 右下角:阶段文字 -->
<TextBlock x:Name="StatusText"
Grid.Column="1"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Opacity="0.8"
Text="Initializing..." />
</Grid>
</Border>
<!-- 底部:进度条 -->
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
IsIndeterminate="False"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</Window>

View File

@@ -1,273 +1,88 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 启动画面窗口 - 简洁设计
/// </summary>
public partial class SplashWindow : Window, ISplashStageReporter
{
private int _versionTextClickCount = 0;
private const int DebugModeClickThreshold = 5;
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
private static readonly TimeSpan SlideAnimationDuration = TimeSpan.FromMilliseconds(260);
private readonly StartupVisualMode _mode;
private int _versionTextClickCount;
private bool _isDebugModeOpened;
private bool _isOpened;
private bool _layoutConfigured;
private bool _dismissed;
private PixelPoint _targetPosition;
private PixelPoint _slideHiddenPosition;
private bool _isDebugModeOpened = false;
public SplashWindow()
: this(StartupVisualMode.Fade)
{
}
public SplashWindow(StartupVisualMode mode)
{
_mode = mode;
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
// 延迟到窗口加载完成后再绑定事件
this.Loaded += OnWindowLoaded;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
// 绑定版本文本点击事件隐藏功能点击5次打开开发者界面
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
if (versionTextBorder is not null)
{
versionBorder.PointerPressed += OnVersionTextClick;
versionTextBorder.PointerPressed += OnVersionTextClick;
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
}
else
{
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
}
}
private async void OnWindowOpened(object? sender, EventArgs e)
{
if (_isOpened)
{
return;
}
_isOpened = true;
ConfigureForVisualMode();
if (_mode == StartupVisualMode.Fade)
{
Opacity = 0d;
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
return;
}
Opacity = 1d;
if (_mode == StartupVisualMode.SlideSplash)
{
await AnimateWindowPositionAsync(_slideHiddenPosition, _targetPosition, SlideAnimationDuration, EaseOutCubic).ConfigureAwait(false);
}
}
public async Task DismissAsync()
{
if (_dismissed)
{
return;
}
_dismissed = true;
ConfigureForVisualMode();
if (_mode == StartupVisualMode.SlideSplash)
{
var from = Position;
await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
}
else if (_mode == StartupVisualMode.Fade)
{
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (IsVisible)
{
Close();
}
});
}
public void Report(string stage, string message)
{
Dispatcher.UIThread.Post(() =>
{
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
{
statusText.Text = message;
}
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
{
var progress = ResolveProgress(stage);
if (progress > 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progress;
}
else
{
progressIndicator.IsIndeterminate = true;
}
}
});
}
public void ReportStage(string stage, int progress)
{
Dispatcher.UIThread.Post(() =>
{
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
{
statusText.Text = stage;
}
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(progress, 0, 100);
}
});
}
public void UpdateProgress(int percent, string? message = null)
{
Dispatcher.UIThread.Post(() =>
{
if (!string.IsNullOrWhiteSpace(message) &&
this.FindControl<TextBlock>("StatusText") is { } statusText)
{
statusText.Text = message;
}
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(percent, 0, 100);
}
});
}
public void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() =>
{
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
{
statusText.Text = message;
}
});
}
public void SetVersionInfo(string version, string codename)
{
Dispatcher.UIThread.Post(() =>
{
if (this.FindControl<TextBlock>("VersionText") is { } versionText)
{
versionText.Text = $"{version} ({codename})";
}
});
}
public void SetDebugMode(bool isDebugMode)
{
if (!isDebugMode)
{
return;
}
UpdateStatus("[Debug Mode] Splash Preview");
}
private void ConfigureForVisualMode()
{
if (_layoutConfigured)
{
return;
}
_layoutConfigured = true;
var compactHero = this.FindControl<Grid>("CompactHero");
var fullscreenHero = this.FindControl<Grid>("FullscreenHero");
if (_mode == StartupVisualMode.Fade)
{
compactHero?.SetCurrentValue(IsVisibleProperty, true);
fullscreenHero?.SetCurrentValue(IsVisibleProperty, false);
Background = new SolidColorBrush(Color.Parse("#0B0B0B"));
Width = 480;
Height = 320;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
return;
}
compactHero?.SetCurrentValue(IsVisibleProperty, false);
fullscreenHero?.SetCurrentValue(IsVisibleProperty, true);
Background = Brushes.Black;
WindowStartupLocation = WindowStartupLocation.Manual;
var screen = Screens?.Primary ?? Screens?.All.FirstOrDefault();
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
var scale = Math.Max(screen?.Scaling ?? 1d, 0.01d);
Width = workingArea.Width / scale;
Height = workingArea.Height / scale;
_targetPosition = new PixelPoint(workingArea.X, workingArea.Y);
_slideHiddenPosition = new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y);
Position = _mode == StartupVisualMode.SlideSplash
? _slideHiddenPosition
: _targetPosition;
}
/// <summary>
/// 版本文本点击事件 - 连续点击5次打开开发者界面隐藏功能
/// </summary>
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
{
if (_isDebugModeOpened)
{
return;
}
if (_isDebugModeOpened) return;
_versionTextClickCount++;
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
if (_versionTextClickCount >= DebugModeClickThreshold)
{
OpenDebugWindow();
}
}
/// <summary>
/// 打开开发者调试窗口
/// </summary>
private async void OpenDebugWindow()
{
_isDebugModeOpened = true;
Console.WriteLine("[SplashWindow] Opening debug window...");
try
{
var debugWindow = new ErrorDebugWindow(
ErrorWindow.CheckDevModeEnabled(),
ErrorWindow.GetSavedCustomHostPath())
// 加载保存的状态
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
{
WindowStartupLocation = WindowStartupLocation.CenterOwner
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
debugWindow.Closed += (_, _) =>
// 订阅窗口关闭事件以保存状态
debugWindow.Closed += (s, e) =>
{
if (debugWindow.WasAccepted)
{
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
debugWindow.IsDevModeEnabled,
debugWindow.SelectedHostPath));
}
Console.WriteLine("[SplashWindow] Debug window closed");
_isDebugModeOpened = false;
_versionTextClickCount = 0;
};
@@ -276,75 +91,160 @@ public partial class SplashWindow : Window, ISplashStageReporter
}
catch (Exception ex)
{
Debug.WriteLine($"[SplashWindow] Failed to open debug window: {ex}");
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
_isDebugModeOpened = false;
_versionTextClickCount = 0;
}
}
private async Task AnimateOpacityAsync(double from, double to, TimeSpan duration)
/// <summary>
/// 更新进度和状态
/// </summary>
public void Report(string stage, string message)
{
await AnimateAsync(progress =>
Dispatcher.UIThread.Post(() =>
{
Opacity = from + ((to - from) * progress);
}, duration, EaseOutCubic).ConfigureAwait(false);
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}");
return;
}
// 更新状态文本
statusText.Text = message;
// 根据阶段更新进度
var progress = ResolveProgress(stage);
if (progress > 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progress;
}
else
{
progressIndicator.IsIndeterminate = true;
}
});
}
private async Task AnimateWindowPositionAsync(
PixelPoint from,
PixelPoint to,
TimeSpan duration,
Func<double, double> easing)
/// <summary>
/// 更新进度0-100
/// </summary>
public void UpdateProgress(int percent, string? message = null)
{
await AnimateAsync(progress =>
Dispatcher.UIThread.Post(() =>
{
var currentX = (int)Math.Round(from.X + ((to.X - from.X) * progress));
var currentY = (int)Math.Round(from.Y + ((to.Y - from.Y) * progress));
Position = new PixelPoint(currentX, currentY);
}, duration, easing).ConfigureAwait(false);
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
return;
}
if (!string.IsNullOrEmpty(message))
{
statusText.Text = message;
}
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(percent, 0, 100);
});
}
private async Task AnimateAsync(Action<double> update, TimeSpan duration, Func<double, double> easing)
/// <summary>
/// 更新状态文本
/// </summary>
public void UpdateStatus(string message)
{
if (duration <= TimeSpan.Zero)
Dispatcher.UIThread.Post(() =>
{
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
return;
}
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < duration)
{
var raw = stopwatch.Elapsed.TotalMilliseconds / duration.TotalMilliseconds;
var progress = easing(Math.Clamp(raw, 0d, 1d));
await Dispatcher.UIThread.InvokeAsync(() => update(progress));
await Task.Delay(16).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
return;
}
statusText.Text = message;
});
}
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
public void ReportStage(string stage, int progress)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage");
return;
}
statusText.Text = stage;
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(progress, 0, 100);
});
}
/// <summary>
/// 设置版本和开发代号
/// </summary>
public void SetVersionInfo(string version, string codename)
{
Dispatcher.UIThread.Post(() =>
{
var versionText = this.FindControl<TextBlock>("VersionText");
if (versionText is null)
{
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
return;
}
versionText.Text = $"{version} ({codename})";
});
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
return;
}
if (isDebugMode)
{
statusText.Text = "[Debug Mode] Splash Preview";
}
});
}
/// <summary>
/// 根据阶段名称解析进度值
/// </summary>
private static int ResolveProgress(string stage)
{
return stage.ToLowerInvariant() switch
{
"initializing" => 10,
"settings" => 25,
"update" => 30,
"plugins" => 50,
"ui" => 65,
"shell" => 80,
"activation" => 90,
"launch" => 70,
"ready" => 100,
_ => 0
};
}
private static double EaseOutCubic(double value)
{
var inverse = 1d - value;
return 1d - (inverse * inverse * inverse);
}
private static double EaseInCubic(double value) => value * value * value;
}

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanMountainDesktop.Launcher"/>
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">

View File

@@ -1,15 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPublicIpcBuilder
{
IPluginPublicIpcBuilder AddService<TContract>(
string? objectId = null,
IEnumerable<string>? notifyIds = null)
where TContract : class;
IPluginPublicIpcBuilder AddService(
Type contractType,
object implementation,
string? objectId = null,
IEnumerable<string>? notifyIds = null);
}

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPublicIpcContributor
{
void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
}

View File

@@ -25,10 +25,8 @@
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPublicIpcServiceDescriptor(
Type ContractType,
object Implementation,
string? ObjectId,
string[] NotifyIds);

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPublicIpcServiceRegistration(
Type ContractType,
string? ObjectId,
string[] NotifyIds);

View File

@@ -1,5 +1,4 @@
using Avalonia.Controls;
using dotnetCampus.Ipc.CompilerServices.Attributes;
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
@@ -113,55 +112,6 @@ public static class PluginServiceCollectionExtensions
return services;
}
public static IServiceCollection AddPluginPublicIpc<TContract, TImplementation>(
this IServiceCollection services,
string? objectId = null,
params string[] notifyIds)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsurePublicIpcContract(typeof(TContract));
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PluginPublicIpcServiceRegistration) &&
descriptor.ImplementationInstance is PluginPublicIpcServiceRegistration existing &&
existing.ContractType == typeof(TContract) &&
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
{
services.AddSingleton(new PluginPublicIpcServiceRegistration(
typeof(TContract),
objectId,
notifyIds ?? []));
}
return services;
}
public static IServiceCollection AddPluginPublicIpcContributor<TContributor>(this IServiceCollection services)
where TContributor : class, IPluginPublicIpcContributor
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IPluginPublicIpcContributor, TContributor>();
return services;
}
private static void EnsurePublicIpcContract(Type contractType)
{
if (!contractType.IsInterface)
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' must be an interface.");
}
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
}
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.0-dev</Version>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>

View File

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

View File

@@ -5,10 +5,8 @@ public enum StartupStage
Initializing,
LoadingSettings,
LoadingPlugins,
TrayReady,
InitializingUI,
ShellInitialized,
BackgroundReady,
DesktopVisible,
ActivationRedirected,
ActivationFailed,
@@ -37,10 +35,4 @@ 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";
}

View File

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

View File

@@ -1,85 +1,231 @@
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,
Delayed,
/// <summary>
/// 失败
/// </summary>
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; }
}

View File

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

View File

@@ -1,9 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicAppInfoService
{
PublicAppInfoSnapshot GetAppInfo();
}

View File

@@ -1,9 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicPluginCatalogService
{
PublicIpcCatalogSnapshot GetCatalog();
}

View File

@@ -1,23 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
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();
Task<bool> ExitAsync();
}

View File

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

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
public sealed record PublicIpcServiceRegistration(
Type ContractType,
Func<IServiceProvider, object> ImplementationFactory,
string? ObjectId,
string? PluginId,
string[] NotifyIds);

View File

@@ -1,83 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddLanMountainDesktopIpcHost(
this IServiceCollection services,
string? pipeName = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(provider =>
{
var host = new PublicIpcHostService(pipeName ?? IpcConstants.DefaultPipeName);
foreach (var registration in provider.GetServices<PublicIpcServiceRegistration>())
{
var implementation = registration.ImplementationFactory(provider);
host.RegisterPublicService(
registration.ContractType,
implementation,
registration.ObjectId,
registration.PluginId,
registration.NotifyIds);
}
host.Start();
return host;
});
services.AddSingleton<IExternalIpcNotificationPublisher>(provider =>
provider.GetRequiredService<PublicIpcHostService>());
return services;
}
public static IServiceCollection AddPublicIpcService<TContract, TImplementation>(
this IServiceCollection services,
string? objectId = null,
string? pluginId = null,
params string[] notifyIds)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PublicIpcServiceRegistration) &&
descriptor.ImplementationInstance is PublicIpcServiceRegistration existing &&
existing.ContractType == typeof(TContract) &&
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
{
services.AddSingleton(new PublicIpcServiceRegistration(
typeof(TContract),
provider => provider.GetRequiredService<TContract>(),
objectId,
pluginId,
notifyIds ?? []));
}
return services;
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
var descriptor = services.LastOrDefault(item => item.ServiceType == typeof(TContract));
if (descriptor is null)
{
services.AddSingleton<TContract, TImplementation>();
return;
}
if (descriptor.Lifetime != ServiceLifetime.Singleton)
{
throw new InvalidOperationException(
$"Public IPC contract '{typeof(TContract).FullName}' must be registered as Singleton.");
}
}
}

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public interface IExternalIpcNotificationPublisher
{
Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
where TPayload : class;
}

View File

@@ -1,14 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public static class IpcConstants
{
public const string DefaultPipeName = "LanMountainDesktop.IPC.v1.Server";
public const string ProtocolVersion = "external-ipc-public-api.v1";
public static class Routes
{
public const string SessionGetInfo = "lanmountain.session.get-info";
public const string CatalogGet = "lanmountain.catalog.get";
}
}

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public static class IpcRoutedNotifyIds
{
public const string CatalogChanged = "lanmountain.catalog.changed";
public const string LauncherStartupProgress = "lanmountain.launcher.startup-progress";
public const string LauncherLoadingState = "lanmountain.launcher.loading-state";
}

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.Shared.IPC</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Public IPC abstractions and host/client infrastructure for LanMountainDesktop, backed by dotnetCampus.Ipc.</Description>
<PackageTags>LanMountainDesktop;IPC;dotnetCampus.Ipc;Integration</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -1,96 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
using dotnetCampus.Ipc.Pipes;
namespace LanMountainDesktop.Shared.IPC;
public sealed class LanMountainDesktopIpcClient : IDisposable
{
private bool _started;
public LanMountainDesktopIpcClient(string? clientPipeName = null)
{
Provider = string.IsNullOrWhiteSpace(clientPipeName)
? new IpcProvider()
: new IpcProvider(clientPipeName);
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
}
public IpcProvider Provider { get; }
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
public PeerProxy? Peer { get; private set; }
public bool IsConnected => Peer is not null && Peer.IsConnectedFinished;
public async Task ConnectAsync(string pipeName = IpcConstants.DefaultPipeName)
{
EnsureStarted();
Peer = await Provider.GetAndConnectToPeerAsync(pipeName).ConfigureAwait(false);
}
public void RegisterNotifyHandler<TPayload>(string notifyId, Action<TPayload> handler)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(handler);
RoutedProvider.AddNotifyHandler(notifyId, handler);
}
public void RegisterNotifyHandler<TPayload>(string notifyId, Func<TPayload, Task> handler)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(handler);
RoutedProvider.AddNotifyHandler(notifyId, handler);
}
public TContract CreateProxy<TContract>(string? objectId = null)
where TContract : class
{
var peer = Peer ?? throw new InvalidOperationException("IPC client is not connected.");
return Provider.CreateIpcProxy<TContract>(peer, objectId);
}
public async Task<PublicIpcCatalogSnapshot?> GetCatalogAsync()
{
var client = await GetRoutedClientAsync().ConfigureAwait(false);
return await client.GetResponseAsync<PublicIpcCatalogSnapshot>(IpcConstants.Routes.CatalogGet)
.ConfigureAwait(false);
}
public async Task<PublicIpcSessionInfo?> GetSessionInfoAsync()
{
var client = await GetRoutedClientAsync().ConfigureAwait(false);
return await client.GetResponseAsync<PublicIpcSessionInfo>(IpcConstants.Routes.SessionGetInfo)
.ConfigureAwait(false);
}
private async Task<JsonIpcDirectRoutedClientProxy> GetRoutedClientAsync()
{
if (Peer is null)
{
throw new InvalidOperationException("IPC client is not connected.");
}
await Task.CompletedTask;
return new JsonIpcDirectRoutedClientProxy(Peer);
}
private void EnsureStarted()
{
if (_started)
{
return;
}
RoutedProvider.StartServer();
_started = true;
}
public void Dispose()
{
Provider.Dispose();
}
}

View File

@@ -1,9 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicAppInfoSnapshot(
string ApplicationName,
string Version,
string Codename,
string PipeName,
int ProcessId,
DateTimeOffset StartedAt);

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcCatalogSnapshot(
PublicIpcServiceDescriptor[] Services,
PublicPluginDescriptor[] Plugins,
DateTimeOffset Timestamp);

View File

@@ -1,219 +0,0 @@
using System.Reflection;
using System.Collections.Concurrent;
using dotnetCampus.Ipc.Context;
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
using dotnetCampus.Ipc.Pipes;
namespace LanMountainDesktop.Shared.IPC;
public sealed class PublicIpcHostService : IDisposable, IExternalIpcNotificationPublisher
{
private static readonly MethodInfo CreateIpcJointMethod = typeof(GeneratedIpcFactory)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Single(method =>
method.Name == nameof(GeneratedIpcFactory.CreateIpcJoint) &&
method.IsGenericMethodDefinition &&
method.GetParameters().Length == 3);
private readonly Dictionary<(Type ContractType, string ObjectId), PublicServiceEntry> _services = new();
private readonly ConcurrentDictionary<string, PeerProxy> _connectedPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly object _gate = new();
private bool _started;
public PublicIpcHostService(string pipeName = IpcConstants.DefaultPipeName)
{
PipeName = pipeName;
StartedAt = DateTimeOffset.UtcNow;
Provider = new IpcProvider(pipeName);
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
}
public string PipeName { get; }
public DateTimeOffset StartedAt { get; }
public IpcProvider Provider { get; }
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
public Func<IReadOnlyList<PublicPluginDescriptor>> PluginDescriptorProvider { get; set; } =
static () => Array.Empty<PublicPluginDescriptor>();
public void Start()
{
if (_started)
{
return;
}
RoutedProvider.AddRequestHandler(IpcConstants.Routes.SessionGetInfo, () => BuildSessionInfo());
RoutedProvider.AddRequestHandler(IpcConstants.Routes.CatalogGet, () => GetCatalogSnapshot());
Provider.PeerConnected += OnPeerConnected;
RoutedProvider.StartServer();
_started = true;
}
public void RegisterPublicService<TContract>(
TContract implementation,
string? objectId = null,
string? pluginId = null,
params string[] notifyIds)
where TContract : class
{
RegisterPublicService(typeof(TContract), implementation, objectId, pluginId, notifyIds);
}
public void RegisterPublicService(
Type contractType,
object implementation,
string? objectId = null,
string? pluginId = null,
IEnumerable<string>? notifyIds = null)
{
ArgumentNullException.ThrowIfNull(contractType);
ArgumentNullException.ThrowIfNull(implementation);
var normalizedObjectId = objectId ?? string.Empty;
var normalizedNotifyIds = notifyIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
lock (_gate)
{
if (_services.ContainsKey((contractType, normalizedObjectId)))
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}' is already registered.");
}
CreateIpcJointMethod
.MakeGenericMethod(contractType)
.Invoke(null, [Provider, implementation, string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId]);
_services[(contractType, normalizedObjectId)] = new PublicServiceEntry(
contractType,
implementation,
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
pluginId,
normalizedNotifyIds);
}
if (_started)
{
_ = NotifyCatalogChangedAsync();
}
}
public PublicIpcCatalogSnapshot GetCatalogSnapshot()
{
PublicIpcServiceDescriptor[] services;
lock (_gate)
{
services = _services.Values
.Select(entry => new PublicIpcServiceDescriptor(
entry.ContractType.FullName ?? entry.ContractType.Name,
entry.ContractType.Assembly.GetName().Name ?? string.Empty,
entry.ContractType.AssemblyQualifiedName,
entry.ObjectId,
entry.PluginId,
string.IsNullOrWhiteSpace(entry.PluginId),
entry.NotifyIds))
.OrderBy(entry => entry.PluginId ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.ContractTypeName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
var plugins = PluginDescriptorProvider()?.ToArray() ?? Array.Empty<PublicPluginDescriptor>();
return new PublicIpcCatalogSnapshot(services, plugins, DateTimeOffset.UtcNow);
}
public Task PublishStartupProgressAsync(
LanMountainDesktop.Shared.Contracts.Launcher.StartupProgressMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherStartupProgress, message, cancellationToken);
}
public Task PublishLoadingStateAsync(
LanMountainDesktop.Shared.Contracts.Launcher.LoadingStateMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, message, cancellationToken);
}
public async Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(payload);
cancellationToken.ThrowIfCancellationRequested();
foreach (var peer in _connectedPeers.Values)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var client = new JsonIpcDirectRoutedClientProxy(peer);
await client.NotifyAsync(notifyId, payload).ConfigureAwait(false);
}
catch
{
// Keep notification fan-out best-effort. Broken peers are cleaned by dotnetCampus.Ipc.
}
}
}
private Task NotifyCatalogChangedAsync()
{
return NotifyAsync(IpcRoutedNotifyIds.CatalogChanged, GetCatalogSnapshot());
}
private PublicIpcSessionInfo BuildSessionInfo()
{
return new PublicIpcSessionInfo(
PipeName,
IpcConstants.ProtocolVersion,
[
IpcConstants.Routes.SessionGetInfo,
IpcConstants.Routes.CatalogGet,
IpcRoutedNotifyIds.CatalogChanged,
IpcRoutedNotifyIds.LauncherStartupProgress,
IpcRoutedNotifyIds.LauncherLoadingState
],
StartedAt);
}
public void Dispose()
{
Provider.PeerConnected -= OnPeerConnected;
Provider.Dispose();
}
private void OnPeerConnected(object? sender, PeerConnectedArgs e)
{
var peer = e.Peer;
_connectedPeers[peer.PeerName] = peer;
peer.PeerConnectionBroken -= OnPeerConnectionBroken;
peer.PeerConnectionBroken += OnPeerConnectionBroken;
}
private void OnPeerConnectionBroken(object? sender, IPeerConnectionBrokenArgs e)
{
if (sender is PeerProxy peer)
{
_connectedPeers.TryRemove(peer.PeerName, out _);
}
}
private sealed record PublicServiceEntry(
Type ContractType,
object Implementation,
string? ObjectId,
string? PluginId,
string[] NotifyIds);
}

View File

@@ -1,10 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcServiceDescriptor(
string ContractTypeName,
string ContractAssemblyName,
string? ContractAssemblyQualifiedName,
string? ObjectId,
string? PluginId,
bool IsBuiltIn,
string[] NotifyIds);

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcSessionInfo(
string PipeName,
string ProtocolVersion,
string[] Capabilities,
DateTimeOffset StartedAt);

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicPluginDescriptor(
string PluginId,
string DisplayName,
string? Version,
bool IsLoaded,
bool IsEnabled);

View File

@@ -1,3 +0,0 @@
# LanMountainDesktop.Shared.IPC
Public IPC abstractions and host/client helpers for LanMountainDesktop.

View File

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

View File

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

View File

@@ -1,120 +0,0 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class ExternalIpcPublicApiTests
{
[Fact]
public async Task PublicIpcHost_ExposesStrongTypedServiceAndCatalog()
{
var pipeName = "LanMountainDesktop.Test." + Guid.NewGuid().ToString("N");
using var host = new PublicIpcHostService(pipeName);
host.PluginDescriptorProvider = () =>
[
new PublicPluginDescriptor("sample.plugin", "Sample Plugin", "1.0.0", true, true)
];
var appInfo = new PublicAppInfoSnapshot(
"LanMountainDesktop",
"1.2.3",
"Administrate",
pipeName,
42,
DateTimeOffset.UtcNow);
host.RegisterPublicService<IPublicAppInfoService>(new TestPublicAppInfoService(appInfo));
host.Start();
using var client = new LanMountainDesktopIpcClient();
var catalogChanged = new TaskCompletionSource<PublicIpcCatalogSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
client.RegisterNotifyHandler<PublicIpcCatalogSnapshot>(IpcRoutedNotifyIds.CatalogChanged, snapshot =>
{
catalogChanged.TrySetResult(snapshot);
});
await client.ConnectAsync(pipeName);
var proxy = client.CreateProxy<IPublicAppInfoService>();
var remoteInfo = proxy.GetAppInfo();
Assert.Equal(appInfo.ApplicationName, remoteInfo.ApplicationName);
Assert.Equal(appInfo.Version, remoteInfo.Version);
Assert.Equal(appInfo.Codename, remoteInfo.Codename);
var initialCatalog = await client.GetCatalogAsync();
Assert.NotNull(initialCatalog);
Assert.Contains(initialCatalog!.Services, service => service.ContractTypeName == typeof(IPublicAppInfoService).FullName);
Assert.Contains(initialCatalog.Plugins, plugin => plugin.PluginId == "sample.plugin");
host.RegisterPublicService<IPublicPluginCatalogService>(new TestPublicPluginCatalogService(initialCatalog));
var updatedCatalog = await catalogChanged.Task.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Contains(updatedCatalog.Services, service => service.ContractTypeName == typeof(IPublicPluginCatalogService).FullName);
var sessionInfo = await client.GetSessionInfoAsync();
Assert.NotNull(sessionInfo);
Assert.Equal(pipeName, sessionInfo!.PipeName);
Assert.Equal(IpcConstants.ProtocolVersion, sessionInfo.ProtocolVersion);
}
[Fact]
public void AddPluginPublicIpc_RegistersServiceDescriptor()
{
var services = new ServiceCollection();
services.AddPluginPublicIpc<ITestPluginPublicService, TestPluginPublicService>(
objectId: "plugin-service",
notifyIds: ["lanmountain.plugin.sample.updated"]);
using var provider = services.BuildServiceProvider();
var registration = Assert.Single(provider.GetServices<PluginPublicIpcServiceRegistration>());
Assert.Equal(typeof(ITestPluginPublicService), registration.ContractType);
Assert.Equal("plugin-service", registration.ObjectId);
Assert.Contains("lanmountain.plugin.sample.updated", registration.NotifyIds);
}
private sealed class TestPublicAppInfoService : IPublicAppInfoService
{
private readonly PublicAppInfoSnapshot _snapshot;
public TestPublicAppInfoService(PublicAppInfoSnapshot snapshot)
{
_snapshot = snapshot;
}
public PublicAppInfoSnapshot GetAppInfo()
{
return _snapshot;
}
}
private sealed class TestPublicPluginCatalogService : IPublicPluginCatalogService
{
private readonly PublicIpcCatalogSnapshot _snapshot;
public TestPublicPluginCatalogService(PublicIpcCatalogSnapshot snapshot)
{
_snapshot = snapshot;
}
public PublicIpcCatalogSnapshot GetCatalog()
{
return _snapshot;
}
}
}
[dotnetCampus.Ipc.CompilerServices.Attributes.IpcPublic]
public interface ITestPluginPublicService
{
string Ping();
}
public sealed class TestPluginPublicService : ITestPluginPublicService
{
public string Ping()
{
return "pong";
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
<Solution>
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
<Project Path="LanMountainDesktop.Shared.IPC/LanMountainDesktop.Shared.IPC.csproj" />
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<Version>0.0.0-dev</Version>
<Version>1.0.0</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
@@ -31,7 +31,6 @@
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
@@ -90,8 +89,8 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" 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 +100,7 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" 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>

View File

@@ -152,12 +152,8 @@ 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; } = [];

View File

@@ -24,7 +24,7 @@ public sealed class Program
AppLogger.Initialize();
DevPluginOptions.Parse(args);
RegisterGlobalExceptionLogging();
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
if (!singleInstance.IsPrimaryInstance)
@@ -77,16 +77,6 @@ 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.");
}

View File

@@ -1,13 +1,16 @@
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(
@@ -39,34 +42,19 @@ public static class AppRestartService
public static ProcessStartInfo? CreateRestartStartInfo(
string[]? commandLineArgs = null,
string? processPath = null,
string? entryAssemblyLocation = null,
RestartPresentationMode? restartPresentationMode = null)
string? entryAssemblyLocation = null)
{
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
var resolvedProcessPath = NormalizeExistingFile(processPath ?? Environment.ProcessPath);
var resolvedEntryAssemblyPath = NormalizeExistingFile(
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
var resolvedEntryAssemblyPath = NormalizeExistingPath(
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,
normalizedRestartPresentation);
args);
}
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
@@ -74,8 +62,7 @@ public static class AppRestartService
return CreateExecutableStartInfo(
resolvedProcessPath,
resolvedEntryAssemblyPath,
args,
normalizedRestartPresentation);
args);
}
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
@@ -84,8 +71,7 @@ public static class AppRestartService
return CreateDotnetStartInfo(
"dotnet",
resolvedEntryAssemblyPath,
args,
normalizedRestartPresentation);
args);
}
return null;
@@ -94,20 +80,22 @@ public static class AppRestartService
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
return LauncherRuntimeMetadata.GetRestartParentProcessId(commandLineArgs);
}
public static RestartPresentationMode? TryGetRestartPresentationMode(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
return LauncherRuntimeMetadata.GetRestartPresentationMode(commandLineArgs);
foreach (var argument in commandLineArgs)
{
if (TryParseRestartParentProcessId(argument, out var processId))
{
return processId;
}
}
return null;
}
private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs,
RestartPresentationMode restartPresentationMode)
IReadOnlyList<string> commandLineArgs)
{
var startInfo = new ProcessStartInfo
{
@@ -116,17 +104,18 @@ public static class AppRestartService
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
var arguments = new StringBuilder();
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
startInfo.Arguments = arguments.ToString();
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
var args = new System.Text.StringBuilder();
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
private static ProcessStartInfo? CreateDotnetStartInfo(
string dotnetHostPath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs,
RestartPresentationMode restartPresentationMode)
IReadOnlyList<string> commandLineArgs)
{
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
{
@@ -140,182 +129,51 @@ public static class AppRestartService
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
var arguments = new StringBuilder();
arguments.Append(QuoteArgument(entryAssemblyPath));
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
startInfo.Arguments = arguments.ToString();
// UseShellExecute=true 时使用 Arguments 字符串
var args = new System.Text.StringBuilder();
args.Append(QuoteArgument(entryAssemblyPath));
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
private static ProcessStartInfo? TryCreateLauncherStartInfo(
IReadOnlyList<string> commandLineArgs,
string? processPath,
string? entryAssemblyPath,
RestartPresentationMode restartPresentationMode)
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
{
var launcherPath = ResolveLauncherPath(commandLineArgs, processPath, entryAssemblyPath);
if (string.IsNullOrWhiteSpace(launcherPath))
for (var i = 1; i < commandLineArgs.Count; i++)
{
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))
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
var directCandidate = Path.Combine(normalizedRoot, launcherFileName);
if (File.Exists(directCandidate))
{
return directCandidate;
}
startInfo.ArgumentList.Add(commandLineArgs[i]);
}
return null;
}
private static IEnumerable<string?> GetPackageRootCandidates(
IReadOnlyList<string> commandLineArgs,
string? processPath,
string? entryAssemblyPath)
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
{
yield return LauncherRuntimeMetadata.GetPackageRoot(commandLineArgs);
foreach (var path in new[] { entryAssemblyPath, processPath, AppContext.BaseDirectory })
for (var i = 1; i < commandLineArgs.Count; i++)
{
var directory = GetDirectoryFromPath(path);
if (string.IsNullOrWhiteSpace(directory))
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
yield return directory;
yield return Path.GetDirectoryName(directory);
if (builder.Length > 0) builder.Append(' ');
builder.Append(QuoteArgument(commandLineArgs[i]));
}
}
private static string? GetDirectoryFromPath(string? path)
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
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;
}
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static void AppendForwardedArguments(
StringBuilder builder,
IReadOnlyList<string> commandLineArgs,
RestartPresentationMode restartPresentationMode)
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
{
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)}");
if (builder.Length > 0) builder.Append(' ');
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static string QuoteArgument(string value)
@@ -330,7 +188,7 @@ public static class AppRestartService
return value;
}
var builder = new StringBuilder();
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
@@ -348,7 +206,21 @@ public static class AppRestartService
return builder.ToString();
}
private static string? NormalizeExistingFile(string? path)
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)
{
if (string.IsNullOrWhiteSpace(path))
{
@@ -366,24 +238,6 @@ 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))

View File

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

View File

@@ -1,27 +0,0 @@
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 DateTimeOffset _startedAt;
public PublicAppInfoService(DateTimeOffset startedAt)
{
_startedAt = startedAt;
}
public PublicAppInfoSnapshot GetAppInfo()
{
var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
return new PublicAppInfoSnapshot(
"LanMountainDesktop",
versionInfo.Version,
versionInfo.Codename,
IpcConstants.DefaultPipeName,
Environment.ProcessId,
_startedAt);
}
}

View File

@@ -1,19 +0,0 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Services.ExternalIpc;
internal sealed class PublicPluginCatalogService : IPublicPluginCatalogService
{
private readonly PublicIpcHostService _publicIpcHostService;
public PublicPluginCatalogService(PublicIpcHostService publicIpcHostService)
{
_publicIpcHostService = publicIpcHostService;
}
public PublicIpcCatalogSnapshot GetCatalog()
{
return _publicIpcHostService.GetCatalogSnapshot();
}
}

View File

@@ -1,103 +0,0 @@
using Avalonia;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
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(() =>
{
return (Application.Current as App)?.TryActivateMainWindowFromExternalIpc("PublicIpc") == true;
}).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(() =>
{
if (Application.Current is not App app)
{
return false;
}
app.OpenIndependentSettingsModule("PublicIpc", pageTag);
return true;
}).GetTask();
}
public Task<bool> RestartAsync()
{
var lifecycle = App.CurrentHostApplicationLifecycle;
return Task.FromResult(lifecycle?.TryRestart(new HostApplicationLifecycleRequest(
Source: "PublicIpc",
Reason: "External IPC requested restart.")) == true);
}
public Task<bool> ExitAsync()
{
var lifecycle = App.CurrentHostApplicationLifecycle;
return Task.FromResult(lifecycle?.TryExit(new HostApplicationLifecycleRequest(
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));
}
}

View File

@@ -5,7 +5,6 @@ using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services;
@@ -23,13 +22,23 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
app = Application.Current as App;
if (app is null || app.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
if (app?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
return false;
}
return app.TrySubmitShutdown(HostShutdownMode.Exit, request);
app.PrepareForShutdown(isRestart: false, request?.Source ?? "Unknown");
if (Dispatcher.UIThread.CheckAccess())
{
desktop.Shutdown();
}
else
{
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
}
return true;
}
catch (Exception ex)
{
@@ -45,13 +54,6 @@ 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())
{
@@ -103,9 +105,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
"Extensions",
"Plugins");
var app = Application.Current as App;
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
var startInfo = AppRestartService.CreateRestartStartInfo();
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
var launchArgs = startInfo?.Arguments ?? "";
@@ -120,14 +120,16 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
Process.Start(helperStartInfo);
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
return TryExit(request);
}
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
{
var app = Application.Current as App;
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
var startInfo = AppRestartService.CreateRestartStartInfo();
if (startInfo is null)
{
AppLogger.Warn(
@@ -137,7 +139,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
Process.Start(startInfo);
var shutdownRequest = request is null
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
@@ -146,7 +150,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
: request.Reason
};
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
return TryExit(exitRequest);
}
private static string ResolveUpgradeHelperPath()

View File

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

View File

@@ -7,7 +7,9 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Launcher;
/// <summary>
/// Launcher IPC 客户端,用于向 Launcher 报告启动进度
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcClient : IDisposable
{
@@ -16,14 +18,23 @@ 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
@@ -39,6 +50,7 @@ public class LauncherIpcClient : IDisposable
}
catch (TimeoutException)
{
// Launcher 可能没有启动 IPC 服务端,这是正常的
return false;
}
catch (Exception ex)
@@ -48,20 +60,24 @@ 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);
@@ -69,10 +85,12 @@ public class LauncherIpcClient : IDisposable
_pipeClient.Flush();
}
// 将同步写入包装为已完成的 Task
await Task.CompletedTask;
}
catch (IOException)
{
// 管道断开
_isConnected = false;
}
catch (Exception ex)
@@ -82,9 +100,30 @@ public class LauncherIpcClient : IDisposable
}
}
/// <summary>
/// 检查是否从 Launcher 启动
/// 优先检查环境变量回退到命令行参数UseShellExecute=true 时环境变量仍可继承,
/// 命令行参数作为备选确保兼容性)
/// </summary>
public static bool IsLaunchedByLauncher()
{
return LauncherRuntimeMetadata.GetLauncherProcessId(Environment.GetCommandLineArgs()) is not null;
// 优先检查环境变量
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;
}
public void Dispose()

View File

@@ -1,6 +1,6 @@
using System.Timers;
using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.Services.Loading;
@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Services.Loading;
public class LoadingStateReporter : IDisposable
{
private readonly LoadingStateManager _manager;
private readonly IExternalIpcNotificationPublisher? _notificationPublisher;
private readonly LauncherIpcClient? _ipcClient;
private readonly System.Timers.Timer _reportTimer;
private readonly object _lock = new();
private bool _isDisposed;
@@ -36,10 +36,10 @@ public class LoadingStateReporter : IDisposable
public LoadingStateReporter(
LoadingStateManager manager,
IExternalIpcNotificationPublisher? notificationPublisher = null)
LauncherIpcClient? ipcClient = null)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_notificationPublisher = notificationPublisher;
_ipcClient = ipcClient;
// 创建定时上报定时器
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
@@ -80,7 +80,7 @@ public class LoadingStateReporter : IDisposable
/// </summary>
public async Task ReportImmediatelyAsync()
{
if (_isDisposed || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == null) return;
var message = CreateDetailedProgressMessage();
await SendMessageAsync(message);
@@ -91,7 +91,7 @@ public class LoadingStateReporter : IDisposable
/// </summary>
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
{
if (_isDisposed || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == null) return;
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item == null) return;
@@ -121,7 +121,7 @@ public class LoadingStateReporter : IDisposable
/// </summary>
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
{
if (_isDisposed || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == null) return;
var progressMessage = new DetailedProgressMessage
{
@@ -140,7 +140,7 @@ public class LoadingStateReporter : IDisposable
/// </summary>
public async Task ReportErrorAsync(string errorMessage, string? details = null)
{
if (_isDisposed || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == null) return;
var fullMessage = string.IsNullOrEmpty(details)
? errorMessage
@@ -280,7 +280,7 @@ public class LoadingStateReporter : IDisposable
/// </summary>
private async Task SendMessageAsync(DetailedProgressMessage message)
{
if (_notificationPublisher == null) return;
if (_ipcClient == null) return;
// 检查最小上报间隔
var now = DateTimeOffset.UtcNow;
@@ -293,15 +293,15 @@ public class LoadingStateReporter : IDisposable
try
{
// 转换为 StartupProgressMessage 以保持兼容性
var loadingStateMessage = _manager.GetLoadingStateMessage() with
var baseMessage = new StartupProgressMessage
{
Stage = message.Stage,
OverallProgressPercent = message.ProgressPercent,
ProgressPercent = message.ProgressPercent,
Message = FormatMessage(message),
Timestamp = DateTimeOffset.UtcNow
};
await _notificationPublisher.NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, loadingStateMessage);
await _ipcClient.ReportProgressAsync(baseMessage);
_lastReportTime = DateTimeOffset.UtcNow;
}
catch (Exception ex)

View File

@@ -414,7 +414,7 @@ internal sealed class NotificationWindowManager
var screen = GetPrimaryScreen();
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
var scale = screen?.Scaling ?? 1d;
var scale = 1d;
for (var i = 0; i < windows.Count; i++)
{
@@ -432,19 +432,12 @@ internal sealed class NotificationWindowManager
int stackIndex)
{
window.Measure(Size.Infinity);
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 windowWidth = window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
var windowHeight = window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
var margin = (int)Math.Round(Margin * scale);
var spacing = (int)Math.Round(Spacing * scale);
var stackedOffset = stackIndex * (windowHeight + spacing);
var stackedOffset = stackIndex * ((int)Math.Round(windowHeight) + spacing);
return position switch
{
@@ -453,31 +446,31 @@ internal sealed class NotificationWindowManager
workingArea.Y + margin + stackedOffset),
NotificationPosition.TopRight => new PixelPoint(
workingArea.Right - windowWidth - margin,
workingArea.Right - (int)Math.Round(windowWidth) - margin,
workingArea.Y + margin + stackedOffset),
NotificationPosition.TopCenter => new PixelPoint(
workingArea.X + (workingArea.Width - windowWidth) / 2,
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
workingArea.Y + margin + stackedOffset),
NotificationPosition.BottomLeft => new PixelPoint(
workingArea.X + margin,
workingArea.Bottom - windowHeight - margin - stackedOffset),
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
NotificationPosition.BottomRight => new PixelPoint(
workingArea.Right - windowWidth - margin,
workingArea.Bottom - windowHeight - margin - stackedOffset),
workingArea.Right - (int)Math.Round(windowWidth) - margin,
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
NotificationPosition.BottomCenter => new PixelPoint(
workingArea.X + (workingArea.Width - windowWidth) / 2,
workingArea.Bottom - windowHeight - margin - stackedOffset),
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
NotificationPosition.Center => new PixelPoint(
workingArea.X + (workingArea.Width - windowWidth) / 2,
workingArea.Y + (workingArea.Height - windowHeight) / 2),
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
workingArea.Y + (workingArea.Height - (int)Math.Round(windowHeight)) / 2),
_ => new PixelPoint(
workingArea.Right - windowWidth - margin,
workingArea.Right - (int)Math.Round(windowWidth) - margin,
workingArea.Y + margin + stackedOffset)
};
}

View File

@@ -1290,10 +1290,6 @@ 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))
@@ -1341,10 +1337,6 @@ 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))

View File

@@ -14,10 +14,28 @@ 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,
Window? ScreenReferenceWindow = null);
SettingsWindowAnchorTarget AnchorTarget = SettingsWindowAnchorTarget.DesktopDockTrailingEdge,
SettingsWindowFallbackMode FallbackMode = SettingsWindowFallbackMode.ScreenBottomRight);
public interface ISettingsWindowAnchorProvider
{
bool TryGetSettingsWindowAnchorBounds(out PixelRect anchorBounds);
}
public interface ISettingsWindowService
{
@@ -28,6 +46,8 @@ public interface ISettingsWindowService
void Open(SettingsWindowOpenRequest request);
void Close();
void Toggle(SettingsWindowOpenRequest request);
}
internal sealed class SettingsWindowService : ISettingsWindowService
@@ -72,25 +92,27 @@ internal sealed class SettingsWindowService : ISettingsWindowService
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
_window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
ApplyTheme(_window);
var targetPageId = request.PageId ?? _window.ViewModel.CurrentPageId;
_window.ReloadPages(targetPageId);
_window.ReloadPages(request.PageId);
PositionWindow(_window, request);
if (!_window.IsVisible)
{
CenterWindow(_window, request);
_window.Show();
if (request.Owner is not null && request.Owner.IsVisible)
{
_window.Show(request.Owner);
}
else
{
_window.Show();
}
NotifyStateChanged();
CenterWindowLater(_window, request);
PositionWindowLater(_window, request);
return;
}
if (_window.WindowState == WindowState.Minimized)
{
_window.WindowState = WindowState.Normal;
}
_window.Activate();
PositionWindowLater(_window, request);
}
public void Close()
@@ -98,6 +120,17 @@ 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();
@@ -114,7 +147,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_hostApplicationLifecycle,
useSystemChrome);
ApplyTheme(window);
window.ShowInTaskbar = true;
window.ShowInTaskbar = false;
window.Closed += (_, _) =>
{
_window = null;
@@ -123,87 +156,106 @@ internal sealed class SettingsWindowService : ISettingsWindowService
return window;
}
private void CenterWindowLater(SettingsWindow window, SettingsWindowOpenRequest request)
private void PositionWindowLater(SettingsWindow window, SettingsWindowOpenRequest request)
{
Dispatcher.UIThread.Post(
() =>
{
if (!ReferenceEquals(_window, window) || !window.IsVisible)
if (!window.IsVisible)
{
return;
}
CenterWindow(window, request);
PositionWindow(window, request);
},
DispatcherPriority.Background);
}
private static void CenterWindow(SettingsWindow window, SettingsWindowOpenRequest request)
private static void PositionWindow(SettingsWindow window, SettingsWindowOpenRequest 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);
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);
}
}
private static int ResolveWindowWidth(Window window, Window? referenceWindow)
private static void PositionWindowAboveAnchor(Window window, PixelRect anchorBounds, SettingsWindowOpenRequest request)
{
var widthDip = ResolveWindowDimensionDip(window.Bounds.Width, window.Width, window.MinWidth, 1120d);
var scale = ResolveWindowScale(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);
return Math.Max(320, (int)Math.Round(widthDip * scale));
}
private static int ResolveWindowHeight(Window window, Window? referenceWindow)
private static int ResolveWindowHeight(Window window, double scale)
{
var heightDip = ResolveWindowDimensionDip(window.Bounds.Height, window.Height, window.MinHeight, 760d);
var scale = ResolveWindowScale(window, referenceWindow);
var heightDip = window.Bounds.Height > 1 ? window.Bounds.Height : Math.Max(window.Height, window.MinHeight);
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);
@@ -311,38 +363,3 @@ 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);
}
}

View File

@@ -16,7 +16,6 @@ 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;
@@ -65,7 +64,6 @@ 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)
@@ -144,7 +142,6 @@ public sealed class SingleInstanceService : IDisposable
}
_listenCts.Dispose();
_listenerReady.Dispose();
if (_ownsMutex)
{
try
@@ -173,7 +170,6 @@ 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);

View File

@@ -13,7 +13,6 @@ using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.ViewModels;
@@ -202,8 +201,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
?? RenderModes[0];
ApplyTransitionPreferences(appSnapshot.EnableFadeTransition, appSnapshot.EnableSlideTransition);
ShowInTaskbar = appSnapshot.ShowInTaskbar;
EnableSlideTransition = appSnapshot.EnableSlideTransition;
_isInitializing = false;
RefreshPreview();
@@ -236,16 +234,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
return;
}
if (changedKeys.Contains(nameof(AppSettingsSnapshot.EnableSlideTransition)) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFadeTransition)))
if (changedKeys.Contains(nameof(AppSettingsSnapshot.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;
EnableSlideTransition = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).EnableSlideTransition;
}
}
@@ -266,23 +257,11 @@ 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;
@@ -383,29 +362,9 @@ 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.ShowInTaskbar), value);
SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value);
}
private void SaveField<T>(string key, T value)
@@ -420,35 +379,6 @@ 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

View File

@@ -256,14 +256,18 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
{
// 打开设置窗口并导航到插件目录页面
if (Application.Current is App app)
if (Application.Current is App app && app.SettingsWindowService is { } settingsWindowService)
{
app.OpenIndependentSettingsModule("FusedDesktopComponentLibrary", "plugin-catalog");
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
var request = new SettingsWindowOpenRequest(
Source: "FusedDesktopComponentLibrary",
Owner: mainWindow,
PageId: "plugin-catalog");
settingsWindowService.Open(request);
}
// 关闭所在窗口
var window = this.FindAncestorOfType<Window>();
var componentLibraryWindow = this.FindAncestorOfType<Window>();
componentLibraryWindow?.Close();
window?.Close();
}
}

View File

@@ -19,6 +19,7 @@ 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;
@@ -281,7 +282,16 @@ public partial class MainWindow
CloseComponentLibraryWindow(reopenSettings: false);
}
(Application.Current as App)?.OpenIndependentSettingsModule("MainWindowTaskbar");
var app = Application.Current as App;
if (app?.SettingsWindowService is { } settingsWindowService)
{
settingsWindowService.Toggle(new SettingsWindowOpenRequest(
Source: "MainWindowTaskbar",
Owner: this));
return;
}
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
}
private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e)
@@ -2851,6 +2861,34 @@ 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

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