mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001d77968f | ||
|
|
e20462ac2b | ||
|
|
aa7c118d13 | ||
|
|
f51ec309a6 |
152
.github/VERSION_SYNC_INFO.md
vendored
152
.github/VERSION_SYNC_INFO.md
vendored
@@ -1,127 +1,65 @@
|
||||
# 版本号自动同步说明
|
||||
# 版本同步说明
|
||||
|
||||
## 📋 概述
|
||||
## 目标
|
||||
|
||||
从本次更新开始,Release 工作流已配置为**自动同步版本号**,确保应用的每个版本号来源都保持一致。
|
||||
发布版的用户可见版本必须统一指向“应用版本”,不能再出现:
|
||||
|
||||
## 🔄 版本号流转链路
|
||||
- Launcher UI 显示 `1.0.0`
|
||||
- 应用设置页显示 `0.8.x`
|
||||
- `version.json`、安装包、Release 资产名称各写各的
|
||||
|
||||
```
|
||||
Git Tag (v1.0.1)
|
||||
↓
|
||||
[Release 工作流 prepare 任务]
|
||||
↓
|
||||
提取版本号: 1.0.1
|
||||
↓
|
||||
[Update version in .csproj] ✨ 新增步骤
|
||||
↓
|
||||
自动更新 .csproj 文件版本号
|
||||
↓
|
||||
dotnet restore/build
|
||||
↓
|
||||
构建时读取更新后的版本号
|
||||
↓
|
||||
应用内显示版本号 (MainWindow.Localization.cs 动态读取)
|
||||
```
|
||||
## 默认仓库状态
|
||||
|
||||
## 🎯 工作原理
|
||||
仓库内的静态版本现在故意保留为开发占位值:
|
||||
|
||||
### 1. 版本号提取
|
||||
当推送 Git Tag 时(如 `git tag v1.0.1`),Release 工作流的 `prepare` 任务自动提取版本号:
|
||||
- TAG: `v1.0.1` → VERSION: `1.0.1`
|
||||
- `Directory.Build.props`
|
||||
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
- `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||
- `LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj`
|
||||
- `LanMountainDesktop/app.manifest`
|
||||
- `LanMountainDesktop.Launcher/app.manifest`
|
||||
|
||||
### 2. 自动更新 .csproj
|
||||
在三个平台的构建任务中,新增了 **"Update version in .csproj"** 步骤:
|
||||
这些值只是提醒“当前不是正式注入构建”,不能代表发布版本。
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
```powershell
|
||||
$VERSION = "1.0.1"
|
||||
(Get-Content file.csproj) -replace '<Version>.*?</Version>', "<Version>$VERSION</Version>" | Set-Content file.csproj
|
||||
```
|
||||
## Release 工作流怎么做
|
||||
|
||||
**Linux/macOS (Bash)**:
|
||||
```bash
|
||||
VERSION="1.0.1"
|
||||
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" file.csproj
|
||||
```
|
||||
Release 工作流会先从 tag 提取版本:
|
||||
|
||||
### 3. 构建和发布
|
||||
更新后的版本号被用于:
|
||||
- 程序集版本 (`AssemblyVersion`)
|
||||
- 包文件名 (`LanMountainDesktop-1.0.1-win-x64.zip`)
|
||||
- 应用内显示 (About 页面)
|
||||
- GitHub Release 标题
|
||||
- `v0.8.5.2` -> `0.8.5.2`
|
||||
- 程序集四段版本 -> `0.8.5.2`
|
||||
|
||||
## 📍 涉及的文件
|
||||
随后显式执行:
|
||||
|
||||
自动更新的文件:
|
||||
1. `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
- `scripts/Set-ReleaseVersion.ps1`
|
||||
|
||||
## ✅ 使用流程
|
||||
这个步骤会同步更新:
|
||||
|
||||
### 发布新版本
|
||||
- 主程序 `.csproj` 的 `Version`
|
||||
- Launcher `.csproj` 的 `Version`
|
||||
- Shared.Contracts `.csproj` 的 `Version`
|
||||
- `Directory.Build.props`
|
||||
- 主程序 `app.manifest`
|
||||
- Launcher `app.manifest`
|
||||
|
||||
```bash
|
||||
# 1. 更新代码(可选:代码中的版本号现在会自动更新)
|
||||
git add .
|
||||
git commit -m "feat: Add new features"
|
||||
之后构建和发布阶段继续通过 MSBuild 属性注入:
|
||||
|
||||
# 2. 创建版本标签
|
||||
git tag v1.0.1
|
||||
# 或带注释的标签
|
||||
git tag -a v1.0.1 -m "Release v1.0.1"
|
||||
- `Version`
|
||||
- `AssemblyVersion`
|
||||
- `FileVersion`
|
||||
- `InformationalVersion`
|
||||
|
||||
# 3. 推送标签到 GitHub
|
||||
git push origin v1.0.1
|
||||
因此最终会统一落到:
|
||||
|
||||
# 4. Release 工作流自动运行:
|
||||
# - 自动更新 .csproj 文件
|
||||
# - 构建所有平台
|
||||
# - 创建 GitHub Release
|
||||
# - 附带所有平台的发布包
|
||||
```
|
||||
- Launcher UI 读取到的应用版本
|
||||
- 应用设置页显示的版本
|
||||
- `version.json`
|
||||
- 程序集文件版本
|
||||
- Windows manifest
|
||||
- 安装包版本
|
||||
- GitHub Release 资产名称
|
||||
|
||||
## 🔒 版本号一致性保证
|
||||
## 维护规则
|
||||
|
||||
现在应用的三个版本号来源完全同步:
|
||||
|
||||
| 来源 | 说明 | 自动更新 |
|
||||
|------|------|--------|
|
||||
| `.csproj` <Version> | 项目文件版本 | ✅ 是 |
|
||||
| 程序集版本 | 编译时读取 | ✅ 是 |
|
||||
| 应用内显示 | About 页面 | ✅ 是 |
|
||||
| 发布包文件名 | Release 工作流 | ✅ 是 |
|
||||
| GitHub Release | Release 工作流 | ✅ 是 |
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 不需要手动更新
|
||||
- ❌ 不需要在 `.csproj` 中手动修改 Version
|
||||
- ❌ 不需要修改多个地方的版本号
|
||||
|
||||
### 只需执行
|
||||
- ✅ 创建 Git Tag: `git tag v1.0.1`
|
||||
- ✅ 推送 Tag: `git push origin v1.0.1`
|
||||
- ✅ 其他由工作流自动处理
|
||||
|
||||
## 📊 版本号格式
|
||||
|
||||
支持的格式:
|
||||
- ✅ `v1.0.0` (builds -> 1.0.0)
|
||||
- ✅ `v1.2.3` (builds -> 1.2.3)
|
||||
- ✅ `v2.0.0-rc1` (builds -> 2.0.0-rc1, 如果需要)
|
||||
|
||||
## 🛠️ 工作流文件
|
||||
|
||||
更新的工作流文件:
|
||||
- `.github/workflows/release.yml` - Release 工作流
|
||||
|
||||
## 📝 相关文件
|
||||
|
||||
- [MULTIPLATFORM_RELEASE_GUIDE.md](./MULTIPLATFORM_RELEASE_GUIDE.md) - 多平台发布指南
|
||||
- [WORKFLOWS_GUIDE.md](./WORKFLOWS_GUIDE.md) - 工作流使用指南
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-03-04
|
||||
**工作流版本**: 2.0 (自动版本同步)
|
||||
- 日常开发不要手动把仓库默认版本改成正式版本号。
|
||||
- 正式发版只需要打 tag,版本同步交给工作流。
|
||||
- 如果新增新的版本承载点,必须同时补到 `Set-ReleaseVersion.ps1` 和 Release 工作流里。
|
||||
|
||||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@@ -119,6 +119,13 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Stamp release version metadata
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/Set-ReleaseVersion.ps1 `
|
||||
-Version "${{ needs.prepare.outputs.version }}" `
|
||||
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
@@ -364,6 +371,13 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Stamp release version metadata
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/Set-ReleaseVersion.ps1 `
|
||||
-Version "${{ needs.prepare.outputs.version }}" `
|
||||
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
@@ -545,6 +559,13 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Stamp release version metadata
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/Set-ReleaseVersion.ps1 `
|
||||
-Version "${{ needs.prepare.outputs.version }}" `
|
||||
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
|
||||
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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
|
||||
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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
|
||||
6
.trae/specs/independent-settings-window/checklist.md
Normal file
6
.trae/specs/independent-settings-window/checklist.md
Normal file
@@ -0,0 +1,6 @@
|
||||
- [x] 从桌面、托盘、IPC、组件库进入设置时,都会落到同一个设置窗口
|
||||
- [x] 设置已打开时再次触发设置入口,只会聚焦已有窗口,不会切换成关闭
|
||||
- [x] 设置窗口始终拥有独立任务栏图标,不受“桌面主窗口在任务栏显示图标”开关影响
|
||||
- [x] 点击“回到 Windows”后,只隐藏或最小化桌面主窗口,设置窗口保持可见
|
||||
- [x] 启用滑入滑出动画后,只有主窗口参与动画,设置窗口不参与
|
||||
- [x] 点击设置窗口关闭按钮后会真实关闭;再次打开时创建新的居中窗口
|
||||
78
.trae/specs/independent-settings-window/spec.md
Normal file
78
.trae/specs/independent-settings-window/spec.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 独立设置窗口 Spec
|
||||
|
||||
## Why
|
||||
|
||||
- 当前设置窗口仍然带有桌面壳的 owner / anchor 语义,点击“回到 Windows”或触发桌面动画时,容易被一起隐藏或重新定位。
|
||||
- 产品新增了“在任务栏显示图标”和“启用滑入滑出动画”设置,需要明确边界:它们只影响桌面主窗口,不影响设置窗口。
|
||||
- 桌面底栏、托盘菜单、IPC、组件库等入口应当始终打开同一个独立设置窗口,而不是切换成附属浮窗或开关行为。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将设置窗口改为独立顶层窗口,始终使用自己的任务栏按钮和图标。
|
||||
- `SettingsWindowService.Open` 改为幂等的 open-or-focus;重复打开只聚焦已有窗口,并在提供目标页时切换到对应页面。
|
||||
- 移除 `Owner`、锚点定位和 `Toggle` 语义;首次打开按参考屏幕居中,关闭为真实关闭。
|
||||
- 桌面壳的“回到 Windows”、最小化到托盘/任务栏、滑入滑出动画,只影响 `MainWindow`,不会影响设置窗口。
|
||||
- 统一桌面、托盘、IPC、组件库等设置入口,全部走 `OpenIndependentSettingsModule`。
|
||||
- 设置页文案明确“在任务栏显示图标”只控制桌面主窗口;设置窗口始终保留独立任务栏图标。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code:
|
||||
- `LanMountainDesktop/Services/Settings/SettingsWindowService.cs`
|
||||
- `LanMountainDesktop/App.axaml.cs`
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
|
||||
- `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
|
||||
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml`
|
||||
- Affected behavior:
|
||||
- 设置窗口生命周期
|
||||
- 设置入口一致性
|
||||
- 任务栏图标与桌面壳显示边界
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设置窗口为独立顶层窗口
|
||||
|
||||
系统 SHALL 将设置窗口作为独立顶层窗口显示,而不是作为桌面主窗口的附属子窗。
|
||||
|
||||
#### Scenario: 设置窗口拥有独立任务栏图标
|
||||
- **WHEN** 用户打开设置窗口
|
||||
- **THEN** 设置窗口使用独立顶层窗口方式显示
|
||||
- **AND THEN** 设置窗口在任务栏中保留自己的独立按钮和图标
|
||||
- **AND THEN** “在任务栏显示图标”开关不会影响设置窗口的任务栏按钮
|
||||
|
||||
### Requirement: 设置入口统一为 open-or-focus
|
||||
|
||||
系统 SHALL 让所有设置入口打开或聚焦同一个设置窗口实例。
|
||||
|
||||
#### Scenario: 已打开时重复触发设置入口
|
||||
- **WHEN** 设置窗口已经打开,用户再次从桌面、托盘或 IPC 触发打开设置
|
||||
- **THEN** 系统只聚焦现有设置窗口
|
||||
- **AND THEN** 如果请求包含目标页,则导航到目标页
|
||||
- **AND THEN** 不会把已打开的设置窗口当作开关关闭
|
||||
|
||||
### Requirement: 设置窗口不参与桌面壳可见性切换
|
||||
|
||||
系统 SHALL 让桌面壳的隐藏、最小化和进出场动画只作用于主窗口。
|
||||
|
||||
#### Scenario: 回到 Windows 时设置窗口保持可见
|
||||
- **WHEN** 主窗口执行“回到 Windows”并隐藏到托盘或最小化到任务栏
|
||||
- **THEN** 设置窗口保持当前可见状态
|
||||
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
|
||||
|
||||
#### Scenario: 桌面滑入滑出动画不作用于设置窗口
|
||||
- **WHEN** 启用了滑入滑出动画并触发主窗口退场或入场
|
||||
- **THEN** 只有主窗口参与动画
|
||||
- **AND THEN** 设置窗口不会消失,也不会跟随主窗口做进出场动画
|
||||
|
||||
### Requirement: 关闭设置窗口时真实销毁实例
|
||||
|
||||
系统 SHALL 在用户关闭设置窗口时真实关闭该窗口实例。
|
||||
|
||||
#### Scenario: 关闭后再次打开
|
||||
- **WHEN** 用户点击设置窗口右上角关闭按钮
|
||||
- **THEN** 当前设置窗口实例被关闭并销毁
|
||||
- **AND THEN** 下次再次打开设置时创建新的设置窗口实例
|
||||
- **AND THEN** 新窗口按参考屏幕居中显示
|
||||
25
.trae/specs/independent-settings-window/tasks.md
Normal file
25
.trae/specs/independent-settings-window/tasks.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Task 1: 简化设置窗口打开契约
|
||||
- [x] 将 `SettingsWindowOpenRequest` 从 owner / anchor 语义改为目标页 + 参考屏幕语义
|
||||
- [x] 移除 `ISettingsWindowService.Toggle`
|
||||
|
||||
- [x] Task 2: 重做设置窗口服务行为
|
||||
- [x] 设置窗口始终使用 `Show()` 打开
|
||||
- [x] 设置窗口始终 `ShowInTaskbar = true`
|
||||
- [x] 已打开时只聚焦并在需要时切页
|
||||
- [x] 关闭后销毁实例,下次打开重新创建并居中
|
||||
|
||||
- [x] Task 3: 统一设置入口并解耦桌面壳
|
||||
- [x] 桌面底栏设置按钮改为 open-or-focus
|
||||
- [x] 组件库入口改为复用 `OpenIndependentSettingsModule`
|
||||
- [x] 移除 `MainWindow` 上的设置窗口锚点逻辑
|
||||
|
||||
- [x] Task 4: 明确产品边界
|
||||
- [x] 调整“在任务栏显示图标”文案,限定为桌面主窗口
|
||||
- [x] 新增独立设置窗口 feature spec
|
||||
- [x] 在窗口过渡动画 spec 中补充“设置窗口不参与动画”
|
||||
|
||||
- [x] Task 5: 验证
|
||||
- [x] 运行 `dotnet build LanMountainDesktop.slnx -c Debug`
|
||||
- [x] 运行与新 helper 相关的测试
|
||||
10
.trae/specs/launcher-shell-hardening/checklist.md
Normal file
10
.trae/specs/launcher-shell-hardening/checklist.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 验收清单
|
||||
|
||||
- [ ] 设置页重启后,Launcher 能重新接管并恢复到正确展示形态。
|
||||
- [ ] 插件升级辅助程序完成后,回拉的是 Launcher 而不是宿主 exe。
|
||||
- [ ] 已在托盘中的实例再次启动时,不会出现第二个主进程。
|
||||
- [ ] 托盘初始化失败时,应用不会进入无入口的 `TrayOnly`。
|
||||
- [ ] 托盘运行中丢失时,watchdog 能重建或自动恢复前台。
|
||||
- [ ] Launcher UI 版本与应用设置页版本一致。
|
||||
- [ ] 发布 tag `vX.Y.Z.W` 时,manifest、程序集、`version.json`、安装包和资产命名一致。
|
||||
- [ ] 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口、通知动画正常。
|
||||
67
.trae/specs/launcher-shell-hardening/spec.md
Normal file
67
.trae/specs/launcher-shell-hardening/spec.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Launcher 外壳托管、托盘兜底与高分屏动画修复
|
||||
|
||||
## 背景
|
||||
|
||||
当前桌面应用在以下场景存在明显不稳定性:
|
||||
|
||||
- 设置页或升级后的“重启”没有统一回到 Launcher。
|
||||
- 已有实例处于托盘时,再次启动容易误报“窗口未显示”,甚至重复拉起。
|
||||
- 托盘初始化失败或运行中丢失时,应用可能进入无恢复入口状态。
|
||||
- Launcher 和宿主的版本来源不一致,发布后容易出现 UI 版本错乱。
|
||||
- 高分屏和混合缩放环境下,Launcher OOBE、主窗口入场和通知动画存在像素/DIP 混用问题。
|
||||
|
||||
## 目标
|
||||
|
||||
- Launcher 成为正式环境唯一的启动与重启入口。
|
||||
- 进入 `TrayOnly` 前必须先确认托盘可恢复。
|
||||
- Launcher UI 显示的版本号等于应用版本号。
|
||||
- 发布工作流显式同步主程序、Launcher、manifest 和产物版本。
|
||||
- 动画和定位统一按 DIP 与缩放计算。
|
||||
|
||||
## 行为要求
|
||||
|
||||
### 1. 重启接管
|
||||
|
||||
- 应用内重启、插件升级后的重启都必须优先回到 Launcher。
|
||||
- Launcher 对 `SecondaryActivationSucceeded` 只认定为一次成功重定向,不允许再做 fallback 二次拉起。
|
||||
- Launcher 启动成功判定区分三类场景:
|
||||
- 前台启动:`DesktopVisible` 或 `ActivationRedirected`
|
||||
- 重启到最小化:`BackgroundReady`
|
||||
- 重启到托盘:`TrayReady + BackgroundReady`
|
||||
|
||||
### 2. 托盘硬约束
|
||||
|
||||
- 托盘状态机必须至少覆盖:
|
||||
- `Unavailable`
|
||||
- `Initializing`
|
||||
- `Ready`
|
||||
- `Recovering`
|
||||
- `Failed`
|
||||
- `HideMainWindowToTray`、关闭到托盘、重启恢复到托盘前都必须先执行托盘就绪检查。
|
||||
- 如果托盘不可用:
|
||||
- 优先回退到任务栏最小化
|
||||
- 若任务栏入口也不可用,则强制恢复前台可见
|
||||
- 托盘处于隐藏态期间必须运行 watchdog;连续恢复失败时自动恢复主窗口。
|
||||
|
||||
### 3. 版本来源
|
||||
|
||||
- Launcher 只能显示应用版本,不能显示 Launcher 自身硬编码版本。
|
||||
- 版本解析优先顺序:
|
||||
- `version.json`
|
||||
- 主程序文件版本 / 信息版本
|
||||
- `app-<version>` 部署目录
|
||||
- Release 工作流必须显式打版本补丁,避免仓库默认占位值被误当成正式版本。
|
||||
|
||||
### 4. 高分屏动画
|
||||
|
||||
- 主窗口、通知、Launcher OOBE 的动画位移必须使用 DIP 或基于缩放换算后的尺寸。
|
||||
- 不允许直接把 `PixelRect` 宽高当作 `TranslateTransform` 或 `DesiredSize` 的输入。
|
||||
- 淡入和位移动画应并行执行,避免先淡入后滑动造成观感异常。
|
||||
|
||||
## 验收
|
||||
|
||||
- 已在托盘中的实例再次通过 Launcher 启动时,只激活已有实例。
|
||||
- 设置页重启和插件升级重启后,不再出现“窗口未显示但后台已有多个进程”。
|
||||
- 托盘失败时应用仍保持可恢复。
|
||||
- Launcher 与应用设置页显示相同版本。
|
||||
- 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口入场、通知位置与动画正常。
|
||||
14
.trae/specs/launcher-shell-hardening/tasks.md
Normal file
14
.trae/specs/launcher-shell-hardening/tasks.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 任务拆解
|
||||
|
||||
- [x] 为 Launcher/宿主共享新增重启来源、父进程和展示模式参数。
|
||||
- [x] 修复 Launcher 对 `SecondaryActivationSucceeded` 的重复 fallback 拉起。
|
||||
- [x] 让 Launcher 成功判定支持 `TrayReady` 与 `BackgroundReady`。
|
||||
- [x] 应用重启默认优先回到 Launcher,而不是直接回拉宿主 exe。
|
||||
- [x] 抽出独立托盘服务,集中处理创建、刷新、watchdog 与状态流转。
|
||||
- [x] 在进入 `TrayOnly` 前增加托盘就绪校验与回退策略。
|
||||
- [x] 为运行中托盘丢失增加 watchdog 和自动恢复逻辑。
|
||||
- [x] 统一公共 IPC、设置页与 Launcher 的版本读取入口。
|
||||
- [x] 将仓库默认版本改为开发占位值,并在 Release 工作流中加入显式打版本步骤。
|
||||
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
|
||||
- [x] 补充规格与版本同步说明文档。
|
||||
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
|
||||
12
.trae/specs/plugin-process-isolation/checklist.md
Normal file
12
.trae/specs/plugin-process-isolation/checklist.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `plugin.json` 缺省时仍默认为 `in-proc`
|
||||
- [x] 非法 `runtime.mode` 会给出清晰错误
|
||||
- [x] SDK 中已有 Worker 入口和隔离运行模式的公共接口
|
||||
- [x] IPC 契约已拆到独立工程,且不引用 Avalonia
|
||||
- [x] IPC 封装层已集中环境变量、启动参数和通知路由常量
|
||||
- [x] 架构文档已写明一期 `isolated-background`、二期 `isolated-window`
|
||||
- [x] 架构文档已写明 `IPluginExportRegistry` / `IPluginMessageBus` 不再作为隔离插件主边界
|
||||
- [x] 文档已写明 ClassIsland 的借鉴点与取舍
|
||||
- [ ] Host 在 Worker 崩溃时仅降级插件且不中断主程序
|
||||
- [ ] `isolated-background` 的组件、编辑器、设置页完成真实 IPC 回路
|
||||
41
.trae/specs/plugin-process-isolation/spec.md
Normal file
41
.trae/specs/plugin-process-isolation/spec.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Plugin Process Isolation
|
||||
|
||||
## Why
|
||||
|
||||
现有插件体系仍是“同进程 + AssemblyLoadContext 隔离”,无法阻止插件 fatal crash 拖垮 Host,也无法阻止插件直接访问 Host 进程内对象和内存。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 增加插件运行模式概念:`in-proc`、`isolated-background`、`isolated-window`
|
||||
- 一期落地 `isolated-background`
|
||||
- 新建独立 IPC 契约包和 IPC 封装包
|
||||
- 在 `PluginSdk` 中新增 Worker 入口与 `runtime.mode`
|
||||
- 明确隔离模式下不再兼容对象实例共享型 API
|
||||
- 新增正式架构文档说明 UI 方案、迁移策略、残余风险和 ClassIsland 借鉴
|
||||
|
||||
## Impact
|
||||
|
||||
- `LanMountainDesktop.PluginSdk/`
|
||||
- `LanMountainDesktop.PluginTemplate/`
|
||||
- 新增 `LanMountainDesktop.PluginIsolation.Contracts/`
|
||||
- 新增 `LanMountainDesktop.PluginIsolation.Ipc/`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1
|
||||
|
||||
宿主必须同时支持存量 `in-proc` 插件与未来的隔离插件,不得以本次改造打断旧插件加载。
|
||||
|
||||
### Requirement 2
|
||||
|
||||
隔离插件的 Host/Worker 通信必须基于显式 IPC 路由和 DTO,而不是 Host 服务对象实例共享。
|
||||
|
||||
### Requirement 3
|
||||
|
||||
一期必须把后台逻辑隔离为独立 Worker 进程,并显式记录 Host UI 壳层的残余风险。
|
||||
|
||||
### Requirement 4
|
||||
|
||||
仓库文档必须把 ClassIsland IPC 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。
|
||||
12
.trae/specs/plugin-process-isolation/tasks.md
Normal file
12
.trae/specs/plugin-process-isolation/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Tasks
|
||||
|
||||
- [x] 梳理现有插件运行时、组件注册、设置页和共享对象边界
|
||||
- [x] 形成插件进程隔离架构文档
|
||||
- [x] 在 `.trae/specs/plugin-process-isolation/` 下补齐 spec、tasks、checklist
|
||||
- [x] 在 `PluginSdk` 中增加 `runtime.mode`、Worker 入口接口和运行模式枚举
|
||||
- [x] 新建 `LanMountainDesktop.PluginIsolation.Contracts`,沉淀纯 DTO、路由常量、错误码与 JSON context
|
||||
- [x] 新建 `LanMountainDesktop.PluginIsolation.Ipc`,沉淀 ClassIsland 风格的 IPC 包装外壳
|
||||
- [x] 更新插件模板 `plugin.json`,让新插件默认显式声明 `in-proc`
|
||||
- [ ] 在 Host 侧接入真实 Worker 进程拉起与 dotnetCampus.Ipc 传输绑定
|
||||
- [ ] 为 `isolated-background` 构建 Host UI 壳层适配器
|
||||
- [ ] 为故障、心跳、降级与恢复补齐端到端测试
|
||||
@@ -113,6 +113,15 @@
|
||||
- **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`(320ms)和 `FluttermotionToken.Duration.Intro`(400ms)
|
||||
- **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`(DecelerateBezier)
|
||||
|
||||
### Requirement: 设置窗口不参与桌面壳过渡动画
|
||||
|
||||
系统 SHALL 将桌面壳进出场动画限制在主窗口范围内,不影响独立设置窗口。
|
||||
|
||||
#### Scenario: 设置窗口在桌面动画期间保持独立
|
||||
- **WHEN** 主窗口执行滑入、滑出、最小化或恢复动画
|
||||
- **THEN** 设置窗口不参与该动画
|
||||
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: OnMinimizeClick 行为
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
|
||||
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
|
||||
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
|
||||
|
||||
@@ -46,8 +46,8 @@ public sealed class DesktopShellHost : IDesktopShellHost
|
||||
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Exit += (_, _) => _performExitCleanup();
|
||||
_createAndAssignMainWindow(desktop);
|
||||
_startActivationListener();
|
||||
_createAndAssignMainWindow(desktop);
|
||||
}
|
||||
|
||||
_startWeatherRefresh();
|
||||
|
||||
@@ -121,6 +121,7 @@ internal sealed class CommandContext
|
||||
return raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"normal" => "normal",
|
||||
"restart" => "restart",
|
||||
"postinstall" => "postinstall",
|
||||
"apply-update" => "apply-update",
|
||||
"plugin-install" => "plugin-install",
|
||||
@@ -146,6 +147,13 @@ internal sealed class CommandContext
|
||||
continue;
|
||||
}
|
||||
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[++i];
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- 应用程序图标 -->
|
||||
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
@@ -57,8 +57,8 @@ internal sealed class DeploymentLocator
|
||||
Version = ParseVersionFromDirectory(path),
|
||||
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
|
||||
})
|
||||
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current æ ‡è®°çš„æŽ’å‰<EFBFBD>é<EFBFBD>¢
|
||||
.ThenByDescending(x => x.Version) // ç„¶å<EFBFBD>ŽæŒ‰ç‰ˆæœ¬å<EFBFBD>·é™<EFBFBD>åº<EFBFBD>
|
||||
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 鏍囪鐨勬帓鍓嶉潰
|
||||
.ThenByDescending(x => x.Version) // 鐒跺悗鎸夌増鏈彿闄嶅簭
|
||||
.ToList();
|
||||
|
||||
if (validInstallations.Count == 0)
|
||||
@@ -275,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))
|
||||
{
|
||||
@@ -299,7 +299,7 @@ internal sealed class DeploymentLocator
|
||||
return inParent;
|
||||
}
|
||||
|
||||
// 4. å¼€å<EFBFBD>‘模å¼<EFBFBD>:如果å<EFBFBD>¯ç”¨äº†å¼€å<EFBFBD>‘模å¼<EFBFBD>,优先使用ä¿<EFBFBD>å˜çš„自定义路径
|
||||
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
@@ -315,7 +315,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
// 5. å¼€å<EFBFBD>‘模å¼<EFBFBD>:查找主程åº<EFBFBD>项目的输出目录
|
||||
// 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
|
||||
var devPaths = GetDevelopmentPaths(executable);
|
||||
foreach (var devPath in devPaths)
|
||||
{
|
||||
@@ -329,21 +329,21 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫æ<EFBFBD><EFBFBD>å¼€å<EFBFBD>‘路径(开å<EFBFBD>‘模å¼<EFBFBD>)
|
||||
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
|
||||
/// </summary>
|
||||
private static string? ScanDevelopmentPaths(string executable)
|
||||
{
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// ä»?Launcher 项目è¿<EFBFBD>行
|
||||
// 浠?Launcher 椤圭洰杩愯
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<EFBFBD>行
|
||||
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// dev-test 目录
|
||||
// dev-test 鐩綍
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
@@ -359,22 +359,22 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获å<EFBFBD>–å¼€å<EFBFBD>‘环境å<EFBFBD>¯èƒ½çš„主程åº<EFBFBD>è·¯å¾? /// </summary>
|
||||
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// ä»?Launcher 项目è¿<EFBFBD>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// 浠?Launcher 椤圭洰杩愯锛?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»Žè§£å†³æ–¹æ¡ˆæ ¹ç›®å½•è¿<EFBFBD>行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯锛歀anMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// ä»?dev-test 目录è¿<EFBFBD>行
|
||||
// 浠?dev-test 鐩綍杩愯
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
@@ -409,8 +409,8 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最近的N个版æœ? /// </summary>
|
||||
/// <param name="minVersionsToKeep">最少ä¿<EFBFBD>留版本数,默è®?ä¸?/param>
|
||||
/// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? /// </summary>
|
||||
/// <param name="minVersionsToKeep">鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
|
||||
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
||||
{
|
||||
try
|
||||
@@ -438,10 +438,10 @@ internal sealed class DeploymentLocator
|
||||
|
||||
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
|
||||
|
||||
// 确定è¦<EFBFBD>ä¿<EFBFBD>留的版本
|
||||
// 纭畾瑕佷繚鐣欑殑鐗堟湰
|
||||
var versionsToKeep = new HashSet<string>();
|
||||
|
||||
// 1. 总是ä¿<EFBFBD>留当å‰<EFBFBD>版本
|
||||
// 1. 鎬绘槸淇濈暀褰撳墠鐗堟湰
|
||||
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
|
||||
if (currentVersion != null)
|
||||
{
|
||||
@@ -449,7 +449,7 @@ internal sealed class DeploymentLocator
|
||||
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
|
||||
}
|
||||
|
||||
// 2. ä¿<EFBFBD>留最近的N个有效版本(ä¸<EFBFBD>åŒ…æ‹¬å·²æ ‡è®°destroy的)
|
||||
// 2. 淇濈暀鏈€杩戠殑N涓湁鏁堢増鏈紙涓嶅寘鎷凡鏍囪destroy鐨勶級
|
||||
var activeVersions = validDeployments
|
||||
.Where(d => !d.IsDestroyed)
|
||||
.Take(minVersionsToKeep)
|
||||
@@ -461,7 +461,7 @@ internal sealed class DeploymentLocator
|
||||
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
|
||||
}
|
||||
|
||||
// 3. ä¿<EFBFBD>留有快照的版本(用于回滚)
|
||||
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
|
||||
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||
if (Directory.Exists(snapshotDir))
|
||||
{
|
||||
@@ -485,17 +485,17 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照解æž<EFBFBD>错误
|
||||
// 蹇界暐蹇収瑙f瀽閿欒
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照目录访问错误
|
||||
// 蹇界暐蹇収鐩綍璁块棶閿欒
|
||||
}
|
||||
}
|
||||
|
||||
// 清ç<EFBFBD>†ä¸<EFBFBD>需è¦<EFBFBD>的版本
|
||||
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
|
||||
foreach (var deployment in validDeployments)
|
||||
{
|
||||
if (versionsToKeep.Contains(deployment.Path))
|
||||
@@ -509,7 +509,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略å<EFBFBD>–æ¶ˆæ ‡è®°å¤±è´¥
|
||||
// 蹇界暐鍙栨秷鏍囪澶辫触
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -524,11 +524,11 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// å¿½ç•¥æ ‡è®°å¤±è´¥
|
||||
// 蹇界暐鏍囪澶辫触
|
||||
}
|
||||
}
|
||||
|
||||
// å°<EFBFBD>è¯•åˆ é™¤
|
||||
// 灏濊瘯鍒犻櫎
|
||||
try
|
||||
{
|
||||
Directory.Delete(deployment.Path, recursive: true);
|
||||
@@ -536,7 +536,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// å¿½ç•¥åˆ é™¤å¤±è´¥(å<>¯èƒ½æ–‡ä»¶è¢«å<C2AB> ç”?,下次å<C2A1>¯åЍå†<C3A5>试
|
||||
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
|
||||
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
|
||||
}
|
||||
}
|
||||
@@ -544,12 +544,12 @@ internal sealed class DeploymentLocator
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
|
||||
// 忽略清ç<EFBFBD>†å¤±è´¥
|
||||
// 蹇界暐娓呯悊澶辫触
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroy的部署(兼容旧方法)
|
||||
/// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
|
||||
/// </summary>
|
||||
[Obsolete("Use CleanupOldDeployments instead")]
|
||||
public void CleanupDestroyedDeployments()
|
||||
@@ -581,36 +581,17 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从部署目录读å<EFBFBD>–版本信æ<EFBFBD>? /// </summary>
|
||||
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </summary>
|
||||
public AppVersionInfo GetVersionInfo()
|
||||
{
|
||||
var deploymentDir = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var versionFile = Path.Combine(deploymentDir, "version.json");
|
||||
if (File.Exists(versionFile))
|
||||
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var resolved = AppVersionProvider.ResolveFromPackageRoot(_appRoot, executableName);
|
||||
return string.IsNullOrWhiteSpace(resolved.Version)
|
||||
? new AppVersionInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFile);
|
||||
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
|
||||
if (info is not null)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate"
|
||||
}
|
||||
}
|
||||
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
: resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
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.Services;
|
||||
|
||||
@@ -12,7 +13,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root", "launch-source",
|
||||
"app-root",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
@@ -65,6 +66,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
window.Show();
|
||||
return window;
|
||||
});
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
var reporter = (ISplashStageReporter)splashWindow;
|
||||
|
||||
LoadingDetailsWindow? loadingDetailsWindow = null;
|
||||
@@ -77,13 +80,15 @@ internal sealed class LauncherFlowCoordinator
|
||||
});
|
||||
}
|
||||
|
||||
var visibilityTcs = new TaskCompletionSource<StartupStage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var successTcs = new TaskCompletionSource<StartupSuccessState>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var activationFailedTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var lastStage = StartupStage.Initializing;
|
||||
var lastStageMessage = "launcher-started";
|
||||
var startupSuccessTracker = new StartupSuccessTracker(_context);
|
||||
|
||||
var loadingState = new LoadingStateMessage();
|
||||
using var ipcServer = new LauncherIpcServer(message =>
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
@@ -104,15 +109,14 @@ internal sealed class LauncherFlowCoordinator
|
||||
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
|
||||
switch (message.Stage)
|
||||
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
|
||||
{
|
||||
case StartupStage.DesktopVisible:
|
||||
case StartupStage.ActivationRedirected:
|
||||
visibilityTcs.TrySetResult(message.Stage);
|
||||
break;
|
||||
case StartupStage.ActivationFailed:
|
||||
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
|
||||
break;
|
||||
successTcs.TrySetResult(successState);
|
||||
}
|
||||
|
||||
if (message.Stage == StartupStage.ActivationFailed)
|
||||
{
|
||||
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -121,7 +125,21 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcServer.Start();
|
||||
ipcClient.RegisterNotifyHandler<LoadingStateMessage>(IpcRoutedNotifyIds.LauncherLoadingState, message =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
loadingState = message;
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("IPC loading-state callback failed.", ex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
@@ -174,24 +192,28 @@ internal sealed class LauncherFlowCoordinator
|
||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||
}
|
||||
|
||||
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
if (!connected)
|
||||
{
|
||||
Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
|
||||
}
|
||||
|
||||
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||
var completedTask = await Task.WhenAny(
|
||||
visibilityTcs.Task,
|
||||
successTcs.Task,
|
||||
activationFailedTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == visibilityTcs.Task)
|
||||
if (completedTask == successTcs.Task)
|
||||
{
|
||||
var stage = await visibilityTcs.Task.ConfigureAwait(false);
|
||||
var successState = await successTcs.Task.ConfigureAwait(false);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: stage == StartupStage.ActivationRedirected ? "activation_redirected" : "ok",
|
||||
message: stage == StartupStage.ActivationRedirected
|
||||
? "Launcher activation was redirected to the existing desktop instance."
|
||||
: "Desktop is visible and ready.",
|
||||
code: successState.Code,
|
||||
message: successState.Message,
|
||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||
}
|
||||
|
||||
@@ -209,7 +231,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
if (completedTask == processExitTask)
|
||||
{
|
||||
var exitCode = launchOutcome.Process.ExitCode;
|
||||
Logger.Warn($"Host exited before desktop became visible. ExitCode={exitCode}.");
|
||||
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
|
||||
|
||||
if (exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
|
||||
{
|
||||
@@ -228,19 +250,41 @@ internal sealed class LauncherFlowCoordinator
|
||||
code: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_redirected" : "host_exited_early",
|
||||
message: exitCode == HostExitCodes.SecondaryActivationSucceeded
|
||||
? "Host redirected activation to the existing desktop instance."
|
||||
: $"Host exited before the desktop became visible. ExitCode={exitCode}.",
|
||||
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["exitCode"] = exitCode.ToString()
|
||||
})));
|
||||
}
|
||||
|
||||
if (connected && !launchOutcome.Process.HasExited)
|
||||
{
|
||||
var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
|
||||
ipcClient,
|
||||
launchOutcome.Process,
|
||||
successTcs.Task,
|
||||
startupSuccessTracker).ConfigureAwait(false);
|
||||
if (recoveryOutcome is not null)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: recoveryOutcome.Code,
|
||||
message: recoveryOutcome.Message,
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["recoveryActivationAttempted"] = bool.TrueString
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: false,
|
||||
stage: "launch",
|
||||
code: "desktop_not_visible",
|
||||
message: "Host process started, but the desktop never became visible within 30 seconds.",
|
||||
message: "Host process started, but it never reached the required startup state within 30 seconds.",
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["ipcStage"] = lastStage.ToString(),
|
||||
@@ -431,6 +475,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
firstDetails);
|
||||
}
|
||||
|
||||
if (firstAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
||||
}
|
||||
|
||||
if (fallbackMode is null)
|
||||
{
|
||||
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
||||
@@ -728,8 +777,10 @@ internal sealed class LauncherFlowCoordinator
|
||||
StartupStage.Initializing => "initializing",
|
||||
StartupStage.LoadingSettings => "settings",
|
||||
StartupStage.LoadingPlugins => "plugins",
|
||||
StartupStage.TrayReady => "shell",
|
||||
StartupStage.InitializingUI => "ui",
|
||||
StartupStage.ShellInitialized => "shell",
|
||||
StartupStage.BackgroundReady => "ready",
|
||||
StartupStage.DesktopVisible => "ready",
|
||||
StartupStage.ActivationRedirected => "activation",
|
||||
StartupStage.ActivationFailed => "error",
|
||||
@@ -900,6 +951,55 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryConnectToPublicIpcAsync(
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
Process hostProcess,
|
||||
Task<StartupSuccessState> successTask,
|
||||
StartupSuccessTracker startupSuccessTracker)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationAccepted = await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false);
|
||||
if (!activationAccepted)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
|
||||
if (completedTask == successTask)
|
||||
{
|
||||
return await successTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!hostProcess.HasExited)
|
||||
{
|
||||
return startupSuccessTracker.BuildRecoverySuccessState();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Public activation recovery failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private enum HostStartMode
|
||||
{
|
||||
ShellExecute,
|
||||
@@ -941,4 +1041,108 @@ internal sealed class LauncherFlowCoordinator
|
||||
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
|
||||
new(result, process, null, details);
|
||||
}
|
||||
|
||||
private sealed class StartupSuccessTracker
|
||||
{
|
||||
private readonly LaunchSuccessPolicy _policy;
|
||||
private bool _trayReady;
|
||||
private bool _backgroundReady;
|
||||
|
||||
public StartupSuccessTracker(CommandContext context)
|
||||
{
|
||||
var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs);
|
||||
var isRestartLaunch = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_policy = !isRestartLaunch
|
||||
? LaunchSuccessPolicy.Foreground
|
||||
: restartPresentation switch
|
||||
{
|
||||
RestartPresentationMode.Tray => LaunchSuccessPolicy.RestartTray,
|
||||
RestartPresentationMode.Minimized => LaunchSuccessPolicy.RestartBackground,
|
||||
_ => LaunchSuccessPolicy.Foreground
|
||||
};
|
||||
}
|
||||
|
||||
public bool TryResolve(StartupStage stage, out StartupSuccessState successState)
|
||||
{
|
||||
switch (stage)
|
||||
{
|
||||
case StartupStage.ActivationRedirected:
|
||||
successState = new StartupSuccessState(
|
||||
stage,
|
||||
"activation_redirected",
|
||||
"Launcher activation was redirected to the existing desktop instance.");
|
||||
return true;
|
||||
|
||||
case StartupStage.DesktopVisible:
|
||||
successState = new StartupSuccessState(
|
||||
stage,
|
||||
_policy == LaunchSuccessPolicy.Foreground ? "ok" : "desktop_visible_fallback",
|
||||
_policy == LaunchSuccessPolicy.Foreground
|
||||
? "Desktop is visible and ready."
|
||||
: "Desktop recovered in a visible state.");
|
||||
return true;
|
||||
|
||||
case StartupStage.TrayReady:
|
||||
_trayReady = true;
|
||||
break;
|
||||
|
||||
case StartupStage.BackgroundReady:
|
||||
_backgroundReady = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (_policy == LaunchSuccessPolicy.RestartBackground && _backgroundReady)
|
||||
{
|
||||
successState = new StartupSuccessState(
|
||||
StartupStage.BackgroundReady,
|
||||
"background_ready",
|
||||
"Desktop restart completed in the background.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_policy == LaunchSuccessPolicy.RestartTray && _trayReady && _backgroundReady)
|
||||
{
|
||||
successState = new StartupSuccessState(
|
||||
StartupStage.BackgroundReady,
|
||||
"background_ready",
|
||||
"Desktop restart completed with tray recovery ready.");
|
||||
return true;
|
||||
}
|
||||
|
||||
successState = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public StartupSuccessState BuildRecoverySuccessState()
|
||||
{
|
||||
return _policy switch
|
||||
{
|
||||
LaunchSuccessPolicy.RestartTray => new StartupSuccessState(
|
||||
StartupStage.DesktopVisible,
|
||||
"recovery_activation_requested",
|
||||
"Launcher requested a visible recovery because the background restart never confirmed tray readiness."),
|
||||
LaunchSuccessPolicy.RestartBackground => new StartupSuccessState(
|
||||
StartupStage.DesktopVisible,
|
||||
"recovery_activation_requested",
|
||||
"Launcher requested a visible recovery because the background restart never confirmed readiness."),
|
||||
_ => new StartupSuccessState(
|
||||
StartupStage.DesktopVisible,
|
||||
"recovery_activation_requested",
|
||||
"Launcher requested a visible recovery from the running desktop instance.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record StartupSuccessState(
|
||||
StartupStage Stage,
|
||||
string Code,
|
||||
string Message);
|
||||
|
||||
private enum LaunchSuccessPolicy
|
||||
{
|
||||
Foreground,
|
||||
RestartBackground,
|
||||
RestartTray
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
<views:OobeWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid x:Name="ContentGrid">
|
||||
<Grid x:Name="ContentGrid"
|
||||
Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform Y="24" />
|
||||
</Grid.RenderTransform>
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Margin="48" RowDefinitions="*,Auto">
|
||||
<!-- 中央内容区域 -->
|
||||
|
||||
@@ -9,26 +9,18 @@ using Avalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// OOBE(首次使用体验)窗口 - 欢迎页面
|
||||
/// </summary>
|
||||
public partial class OobeWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
private bool _isTransitioning = false;
|
||||
private bool _isTransitioning;
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再初始化
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
|
||||
@@ -45,31 +37,29 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件 - 播放入场动画
|
||||
/// </summary>
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
|
||||
await PlayEntranceAnimationAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放入场动画
|
||||
/// </summary>
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取内容元素
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接返回
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡入动画
|
||||
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
|
||||
contentGrid.RenderTransform = translateTransform;
|
||||
|
||||
var offset = ResolveEntranceOffset();
|
||||
contentGrid.Opacity = 0;
|
||||
translateTransform.Y = offset;
|
||||
|
||||
var fadeInAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
@@ -89,7 +79,6 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
};
|
||||
|
||||
// 创建向上滑动动画
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
@@ -98,7 +87,7 @@ public partial class OobeWindow : Window
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
|
||||
Setters = { new Setter(TranslateTransform.YProperty, offset) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
@@ -109,9 +98,9 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
};
|
||||
|
||||
// 应用动画
|
||||
await fadeInAnimation.RunAsync(contentGrid);
|
||||
await slideUpAnimation.RunAsync(contentGrid);
|
||||
await Task.WhenAll(
|
||||
fadeInAnimation.RunAsync(contentGrid),
|
||||
slideUpAnimation.RunAsync(translateTransform));
|
||||
|
||||
Console.WriteLine("[OobeWindow] Entrance animation completed");
|
||||
}
|
||||
@@ -121,27 +110,21 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户点击开始按钮
|
||||
/// </summary>
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
/// <summary>
|
||||
/// 进入按钮点击事件
|
||||
/// </summary>
|
||||
private async void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
_isTransitioning = true;
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTransitioning = true;
|
||||
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
|
||||
|
||||
try
|
||||
{
|
||||
// 播放退出动画
|
||||
await PlayExitAnimationAsync();
|
||||
|
||||
// 完成 OOBE
|
||||
_completionSource.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -151,9 +134,6 @@ public partial class OobeWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放退出动画
|
||||
/// </summary>
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
try
|
||||
@@ -161,12 +141,10 @@ public partial class OobeWindow : Window
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接延迟后返回
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡出动画
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
@@ -194,4 +172,11 @@ public partial class OobeWindow : Window
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveEntranceOffset()
|
||||
{
|
||||
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
|
||||
var scaledOffset = boundsHeight * 0.05;
|
||||
return Math.Clamp(scaledOffset, 20, 48);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
|
||||
<assemblyIdentity version="0.0.0.0" name="LanMountainDesktop.Launcher"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginAppearanceSnapshotRequest(string SessionId);
|
||||
|
||||
public sealed record PluginAppearanceSnapshot(
|
||||
string ThemeVariant,
|
||||
string? AccentColor = null,
|
||||
double CornerRadiusScale = 1.0,
|
||||
IReadOnlyDictionary<string, double>? CornerRadiusTokens = null,
|
||||
IReadOnlyDictionary<string, string>? ResourceAliases = null);
|
||||
|
||||
public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot);
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginHeartbeatPing(
|
||||
string SessionId,
|
||||
DateTimeOffset SentAtUtc);
|
||||
|
||||
public sealed record PluginHeartbeatPong(
|
||||
string SessionId,
|
||||
DateTimeOffset ReceivedAtUtc);
|
||||
|
||||
public sealed record PluginLogEntry(
|
||||
string Level,
|
||||
string Category,
|
||||
string Message,
|
||||
DateTimeOffset TimestampUtc,
|
||||
string? Exception = null);
|
||||
|
||||
public static class PluginLogLevels
|
||||
{
|
||||
public const string Trace = "trace";
|
||||
public const string Debug = "debug";
|
||||
public const string Information = "information";
|
||||
public const string Warning = "warning";
|
||||
public const string Error = "error";
|
||||
public const string Critical = "critical";
|
||||
}
|
||||
|
||||
public sealed record PluginFaultReport(
|
||||
string SessionId,
|
||||
string FaultKind,
|
||||
bool IsFatal,
|
||||
string Message,
|
||||
string? StackTrace = null,
|
||||
int? WorkerProcessId = null,
|
||||
int? ExitCode = null,
|
||||
DateTimeOffset? OccurredAtUtc = null);
|
||||
|
||||
public static class PluginFaultKinds
|
||||
{
|
||||
public const string ManagedException = "managed-exception";
|
||||
public const string NativeCrash = "native-crash";
|
||||
public const string WatchdogTimeout = "watchdog-timeout";
|
||||
public const string StartupFailure = "startup-failure";
|
||||
public const string ForcedTermination = "forced-termination";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginIsolation.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Transport-neutral IPC contracts for the LanMountainDesktop plugin isolation architecture.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;Contracts</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginInitializeRequest(
|
||||
string PluginId,
|
||||
string SessionId,
|
||||
string HostPipeName,
|
||||
string DataDirectory,
|
||||
IReadOnlyDictionary<string, string>? StartupProperties = null);
|
||||
|
||||
public sealed record PluginInitializeResponse(
|
||||
bool Succeeded,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed record PluginStopRequest(
|
||||
string Reason,
|
||||
bool RestartRequested = false);
|
||||
|
||||
public sealed record PluginRestartRequest(string Reason);
|
||||
|
||||
public sealed record PluginLifecycleStateChanged(
|
||||
string State,
|
||||
string? Detail = null);
|
||||
|
||||
public static class PluginLifecycleStates
|
||||
{
|
||||
public const string Starting = "starting";
|
||||
public const string Ready = "ready";
|
||||
public const string Degraded = "degraded";
|
||||
public const string Stopping = "stopping";
|
||||
public const string Stopped = "stopped";
|
||||
public const string Faulted = "faulted";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginCapabilityDeclaration(
|
||||
string Name,
|
||||
string Version,
|
||||
string? Description = null);
|
||||
|
||||
public static class PluginCapabilityNames
|
||||
{
|
||||
public const string Settings = "settings";
|
||||
public const string Appearance = "appearance";
|
||||
public const string DesktopComponentUi = "ui.desktop-component";
|
||||
public const string ComponentEditorUi = "ui.component-editor";
|
||||
public const string SettingsPageUi = "ui.settings-page";
|
||||
public const string Logging = "diagnostics.log";
|
||||
public const string FaultReporting = "diagnostics.fault";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public static class PluginIpcErrorCodes
|
||||
{
|
||||
public const string ProtocolMismatch = "protocol_mismatch";
|
||||
public const string SessionRejected = "session_rejected";
|
||||
public const string CapabilityDenied = "capability_denied";
|
||||
public const string InvalidRequest = "invalid_request";
|
||||
public const string UnsupportedRoute = "unsupported_route";
|
||||
public const string SettingsConflict = "settings_conflict";
|
||||
public const string UiAttachRejected = "ui_attach_rejected";
|
||||
public const string WorkerFaulted = "worker_faulted";
|
||||
public const string WorkerExited = "worker_exited";
|
||||
public const string HeartbeatTimeout = "heartbeat_timeout";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public static class PluginIpcRoutes
|
||||
{
|
||||
public static class Session
|
||||
{
|
||||
public const string Handshake = "session/handshake";
|
||||
public const string Capabilities = "session/capabilities";
|
||||
public const string Ready = "session/ready";
|
||||
}
|
||||
|
||||
public static class Lifecycle
|
||||
{
|
||||
public const string Initialize = "lifecycle/initialize";
|
||||
public const string Stop = "lifecycle/stop";
|
||||
public const string RestartRequest = "lifecycle/restart-request";
|
||||
public const string StateChanged = "lifecycle/state-changed";
|
||||
}
|
||||
|
||||
public static class Settings
|
||||
{
|
||||
public const string GetSnapshot = "settings/get-snapshot";
|
||||
public const string Write = "settings/write";
|
||||
public const string Changed = "settings/changed";
|
||||
}
|
||||
|
||||
public static class Appearance
|
||||
{
|
||||
public const string GetSnapshot = "appearance/get-snapshot";
|
||||
public const string Changed = "appearance/changed";
|
||||
}
|
||||
|
||||
public static class Ui
|
||||
{
|
||||
public const string Attach = "ui/attach";
|
||||
public const string Detach = "ui/detach";
|
||||
public const string Command = "ui/command";
|
||||
public const string StateChanged = "ui/state-changed";
|
||||
}
|
||||
|
||||
public static class Heartbeat
|
||||
{
|
||||
public const string Ping = "heartbeat/ping";
|
||||
public const string Pong = "heartbeat/pong";
|
||||
}
|
||||
|
||||
public static class Log
|
||||
{
|
||||
public const string Write = "log/write";
|
||||
}
|
||||
|
||||
public static class Fault
|
||||
{
|
||||
public const string Report = "fault/report";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(PluginCapabilityDeclaration))]
|
||||
[JsonSerializable(typeof(List<PluginCapabilityDeclaration>))]
|
||||
[JsonSerializable(typeof(PluginSessionHandshakeRequest))]
|
||||
[JsonSerializable(typeof(PluginSessionHandshakeResponse))]
|
||||
[JsonSerializable(typeof(PluginReadyNotification))]
|
||||
[JsonSerializable(typeof(PluginInitializeRequest))]
|
||||
[JsonSerializable(typeof(PluginInitializeResponse))]
|
||||
[JsonSerializable(typeof(PluginStopRequest))]
|
||||
[JsonSerializable(typeof(PluginRestartRequest))]
|
||||
[JsonSerializable(typeof(PluginLifecycleStateChanged))]
|
||||
[JsonSerializable(typeof(PluginSettingsSnapshotRequest))]
|
||||
[JsonSerializable(typeof(PluginSettingsSnapshotResponse))]
|
||||
[JsonSerializable(typeof(PluginSettingsWriteRequest))]
|
||||
[JsonSerializable(typeof(PluginSettingsWriteResponse))]
|
||||
[JsonSerializable(typeof(PluginSettingsChangedNotification))]
|
||||
[JsonSerializable(typeof(PluginAppearanceSnapshotRequest))]
|
||||
[JsonSerializable(typeof(PluginAppearanceSnapshot))]
|
||||
[JsonSerializable(typeof(PluginAppearanceChangedNotification))]
|
||||
[JsonSerializable(typeof(PluginUiSurfaceDescriptor))]
|
||||
[JsonSerializable(typeof(List<PluginUiSurfaceDescriptor>))]
|
||||
[JsonSerializable(typeof(PluginUiAttachRequest))]
|
||||
[JsonSerializable(typeof(PluginUiAttachResponse))]
|
||||
[JsonSerializable(typeof(PluginUiDetachNotification))]
|
||||
[JsonSerializable(typeof(PluginUiCommandRequest))]
|
||||
[JsonSerializable(typeof(PluginUiCommandResponse))]
|
||||
[JsonSerializable(typeof(PluginUiStateChangedNotification))]
|
||||
[JsonSerializable(typeof(PluginHeartbeatPing))]
|
||||
[JsonSerializable(typeof(PluginHeartbeatPong))]
|
||||
[JsonSerializable(typeof(PluginLogEntry))]
|
||||
[JsonSerializable(typeof(PluginFaultReport))]
|
||||
public partial class PluginIsolationJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public static class PluginIsolationProtocolVersion
|
||||
{
|
||||
public const string Current = "1.0";
|
||||
}
|
||||
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal file
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# LanMountainDesktop.PluginIsolation.Contracts
|
||||
|
||||
Transport-neutral DTOs, route constants, protocol versioning, and JSON serialization context for plugin process isolation.
|
||||
|
||||
## Includes
|
||||
|
||||
- route groups for session, lifecycle, settings, appearance, UI, heartbeat, log, and fault
|
||||
- explicit DTOs for routed request and notification payloads
|
||||
- source-generated `System.Text.Json` context for the IPC protocol
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginSessionHandshakeRequest(
|
||||
string PluginId,
|
||||
string SessionId,
|
||||
string RuntimeMode,
|
||||
string ProtocolVersion,
|
||||
IReadOnlyList<PluginCapabilityDeclaration>? RequestedCapabilities = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
public sealed record PluginSessionHandshakeResponse(
|
||||
bool Accepted,
|
||||
string ProtocolVersion,
|
||||
IReadOnlyList<PluginCapabilityDeclaration>? GrantedCapabilities = null,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed record PluginReadyNotification(
|
||||
string PluginId,
|
||||
string SessionId,
|
||||
IReadOnlyList<PluginUiSurfaceDescriptor>? UiSurfaces = null);
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginSettingsSnapshotRequest(
|
||||
string Scope,
|
||||
string? SectionId = null,
|
||||
string? ComponentInstanceId = null);
|
||||
|
||||
public sealed record PluginSettingsSnapshotResponse(
|
||||
string Scope,
|
||||
JsonElement Snapshot,
|
||||
string? ETag = null);
|
||||
|
||||
public sealed record PluginSettingsWriteRequest(
|
||||
string Scope,
|
||||
JsonElement Value,
|
||||
string? SectionId = null,
|
||||
string? ComponentInstanceId = null,
|
||||
string? ETag = null);
|
||||
|
||||
public sealed record PluginSettingsWriteResponse(
|
||||
bool Accepted,
|
||||
string? ETag = null,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed record PluginSettingsChangedNotification(
|
||||
string Scope,
|
||||
JsonElement Value,
|
||||
string? SectionId = null,
|
||||
string? ComponentInstanceId = null,
|
||||
string? ETag = null);
|
||||
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal file
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public sealed record PluginUiSurfaceDescriptor(
|
||||
string SurfaceId,
|
||||
string SurfaceKind,
|
||||
string Title,
|
||||
string? ComponentId = null);
|
||||
|
||||
public static class PluginUiSurfaceKinds
|
||||
{
|
||||
public const string DesktopComponent = "desktop-component";
|
||||
public const string ComponentEditor = "component-editor";
|
||||
public const string SettingsPage = "settings-page";
|
||||
public const string Window = "window";
|
||||
}
|
||||
|
||||
public sealed record PluginUiAttachRequest(
|
||||
string SurfaceId,
|
||||
string SurfaceKind,
|
||||
string? InstanceId = null,
|
||||
JsonElement? InitialState = null);
|
||||
|
||||
public sealed record PluginUiAttachResponse(
|
||||
bool Accepted,
|
||||
JsonElement? InitialState = null,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed record PluginUiDetachNotification(
|
||||
string SurfaceId,
|
||||
string SurfaceKind,
|
||||
string? InstanceId = null);
|
||||
|
||||
public sealed record PluginUiCommandRequest(
|
||||
string SurfaceId,
|
||||
string CommandName,
|
||||
string? InstanceId = null,
|
||||
JsonElement? Payload = null);
|
||||
|
||||
public sealed record PluginUiCommandResponse(
|
||||
bool Accepted,
|
||||
JsonElement? Payload = null,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed record PluginUiStateChangedNotification(
|
||||
string SurfaceId,
|
||||
string SurfaceKind,
|
||||
string? InstanceId = null,
|
||||
JsonElement? State = null);
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginIsolation.Ipc</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>ClassIsland-style IPC facade for LanMountainDesktop plugin process isolation, backed by dotnetCampus.Ipc.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;dotnetCampus.Ipc</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal file
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public sealed class PluginIpcClient
|
||||
{
|
||||
public PluginIpcClient(PluginIpcClientOptions options)
|
||||
{
|
||||
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
|
||||
SerializerOptions = SerializerContext.Options;
|
||||
}
|
||||
|
||||
public PluginIpcClientOptions Options { get; }
|
||||
|
||||
public JsonSerializerContext SerializerContext { get; }
|
||||
|
||||
public JsonSerializerOptions SerializerOptions { get; }
|
||||
|
||||
public PluginIpcRequestDispatcher? RequestDispatcher { get; set; }
|
||||
|
||||
public PluginIpcNotificationDispatcher? NotificationDispatcher { get; set; }
|
||||
|
||||
public Task<TResponse?> RequestAsync<TRequest, TResponse>(
|
||||
string route,
|
||||
TRequest payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
return RequestCoreAsync<TRequest, TResponse>(route, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyAsync<TPayload>(
|
||||
string route,
|
||||
TPayload payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
return NotifyCoreAsync(route, Serialize(payload), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TResponse?> RequestCoreAsync<TRequest, TResponse>(
|
||||
string route,
|
||||
TRequest payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (RequestDispatcher is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
|
||||
"Wire RequestDispatcher during host/worker transport integration.");
|
||||
}
|
||||
|
||||
var response = await RequestDispatcher(route, Serialize(payload), cancellationToken).ConfigureAwait(false);
|
||||
if (response is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return Deserialize<TResponse>(response);
|
||||
}
|
||||
|
||||
private async Task NotifyCoreAsync(string route, JsonElement? payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (NotificationDispatcher is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
|
||||
"Wire NotificationDispatcher during host/worker transport integration.");
|
||||
}
|
||||
|
||||
await NotificationDispatcher(route, payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private JsonElement Serialize<T>(T payload)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
|
||||
}
|
||||
|
||||
private T? Deserialize<T>(JsonElement? payload)
|
||||
{
|
||||
if (payload is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return payload.Value.Deserialize<T>(SerializerOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public sealed record PluginIpcClientOptions
|
||||
{
|
||||
public required string PipeName { get; init; }
|
||||
|
||||
public string ProtocolVersion { get; init; } = PluginIsolationProtocolVersion.Current;
|
||||
|
||||
public TimeSpan ConnectTimeout { get; init; } = PluginIpcConstants.DefaultConnectTimeout;
|
||||
|
||||
public TimeSpan RequestTimeout { get; init; } = PluginIpcConstants.DefaultRequestTimeout;
|
||||
|
||||
public JsonSerializerContext SerializerContext { get; init; } = PluginIsolationJsonContext.Default;
|
||||
}
|
||||
25
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs
Normal file
25
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public static class PluginIpcConstants
|
||||
{
|
||||
public const string EnvironmentPluginId = "LANMOUNTAIN_PLUGIN_ID";
|
||||
public const string EnvironmentSessionId = "LANMOUNTAIN_PLUGIN_SESSION_ID";
|
||||
public const string EnvironmentHostPipeName = "LANMOUNTAIN_PLUGIN_HOST_PIPE";
|
||||
public const string EnvironmentProtocolVersion = "LANMOUNTAIN_PLUGIN_PROTOCOL_VERSION";
|
||||
public const string EnvironmentRuntimeMode = "LANMOUNTAIN_PLUGIN_RUNTIME_MODE";
|
||||
|
||||
public const string CommandLinePluginId = "--plugin-id";
|
||||
public const string CommandLineSessionId = "--session-id";
|
||||
public const string CommandLineHostPipeName = "--host-pipe-name";
|
||||
public const string CommandLineProtocolVersion = "--protocol-version";
|
||||
public const string CommandLineRuntimeMode = "--runtime-mode";
|
||||
|
||||
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
|
||||
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5);
|
||||
public static readonly TimeSpan DefaultHeartbeatTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
public const string DefaultProtocolVersion = PluginIsolationProtocolVersion.Current;
|
||||
}
|
||||
13
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs
Normal file
13
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public delegate Task<JsonElement?> PluginIpcRequestDispatcher(
|
||||
string route,
|
||||
JsonElement? payload,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
public delegate Task PluginIpcNotificationDispatcher(
|
||||
string route,
|
||||
JsonElement? payload,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -0,0 +1,17 @@
|
||||
using LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public static class PluginIpcRoutedNotifyIds
|
||||
{
|
||||
public const string SessionReady = PluginIpcRoutes.Session.Ready;
|
||||
public const string LifecycleStateChanged = PluginIpcRoutes.Lifecycle.StateChanged;
|
||||
public const string SettingsChanged = PluginIpcRoutes.Settings.Changed;
|
||||
public const string AppearanceChanged = PluginIpcRoutes.Appearance.Changed;
|
||||
public const string UiDetach = PluginIpcRoutes.Ui.Detach;
|
||||
public const string UiStateChanged = PluginIpcRoutes.Ui.StateChanged;
|
||||
public const string HeartbeatPing = PluginIpcRoutes.Heartbeat.Ping;
|
||||
public const string HeartbeatPong = PluginIpcRoutes.Heartbeat.Pong;
|
||||
public const string LogWrite = PluginIpcRoutes.Log.Write;
|
||||
public const string FaultReport = PluginIpcRoutes.Fault.Report;
|
||||
}
|
||||
113
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs
Normal file
113
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public sealed class PluginIpcServer
|
||||
{
|
||||
private readonly Dictionary<string, Func<JsonElement?, CancellationToken, Task<JsonElement?>>> _requestHandlers =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Dictionary<string, Func<JsonElement?, CancellationToken, Task>> _notificationHandlers =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginIpcServer(PluginIpcServerOptions options)
|
||||
{
|
||||
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
|
||||
SerializerOptions = SerializerContext.Options;
|
||||
}
|
||||
|
||||
public PluginIpcServerOptions Options { get; }
|
||||
|
||||
public JsonSerializerContext SerializerContext { get; }
|
||||
|
||||
public JsonSerializerOptions SerializerOptions { get; }
|
||||
|
||||
public void MapRequest<TRequest, TResponse>(
|
||||
string route,
|
||||
Func<TRequest, CancellationToken, Task<TResponse>> handler)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
_requestHandlers[route] = async (payload, cancellationToken) =>
|
||||
{
|
||||
var request = Deserialize<TRequest>(payload);
|
||||
var response = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
return Serialize(response);
|
||||
};
|
||||
}
|
||||
|
||||
public void MapNotification<TPayload>(
|
||||
string route,
|
||||
Func<TPayload, CancellationToken, Task> handler)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
_notificationHandlers[route] = (payload, cancellationToken) =>
|
||||
{
|
||||
var notification = Deserialize<TPayload>(payload);
|
||||
return handler(notification, cancellationToken);
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<JsonElement?> HandleRequestAsync(
|
||||
string route,
|
||||
JsonElement? payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
|
||||
if (!_requestHandlers.TryGetValue(route, out var handler))
|
||||
{
|
||||
throw new InvalidOperationException($"No IPC request handler is registered for route '{route}'.");
|
||||
}
|
||||
|
||||
return await handler(payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task HandleNotificationAsync(
|
||||
string route,
|
||||
JsonElement? payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(route);
|
||||
|
||||
if (!_notificationHandlers.TryGetValue(route, out var handler))
|
||||
{
|
||||
throw new InvalidOperationException($"No IPC notification handler is registered for route '{route}'.");
|
||||
}
|
||||
|
||||
return handler(payload, cancellationToken);
|
||||
}
|
||||
|
||||
private JsonElement Serialize<T>(T payload)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
|
||||
}
|
||||
|
||||
private T Deserialize<T>(JsonElement? payload)
|
||||
{
|
||||
if (payload is null)
|
||||
{
|
||||
if (default(T) is null)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"IPC payload is required for '{typeof(T).FullName}', but the caller provided no payload.");
|
||||
}
|
||||
|
||||
var value = payload.Value.Deserialize<T>(SerializerOptions);
|
||||
if (value is null && default(T) is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to deserialize IPC payload to '{typeof(T).FullName}'.");
|
||||
}
|
||||
|
||||
return value!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginIsolation.Ipc;
|
||||
|
||||
public sealed record PluginIpcServerOptions
|
||||
{
|
||||
public required string PipeName { get; init; }
|
||||
|
||||
public string ProtocolVersion { get; init; } = PluginIsolationProtocolVersion.Current;
|
||||
|
||||
public TimeSpan HeartbeatInterval { get; init; } = PluginIpcConstants.DefaultHeartbeatInterval;
|
||||
|
||||
public TimeSpan HeartbeatTimeout { get; init; } = PluginIpcConstants.DefaultHeartbeatTimeout;
|
||||
|
||||
public JsonSerializerContext SerializerContext { get; init; } = PluginIsolationJsonContext.Default;
|
||||
}
|
||||
10
LanMountainDesktop.PluginIsolation.Ipc/README.md
Normal file
10
LanMountainDesktop.PluginIsolation.Ipc/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# LanMountainDesktop.PluginIsolation.Ipc
|
||||
|
||||
ClassIsland-inspired IPC facade for LanMountainDesktop plugin isolation.
|
||||
|
||||
## Includes
|
||||
|
||||
- host and worker startup constants
|
||||
- centralized routed notification IDs
|
||||
- transport-neutral routed client and server wrappers
|
||||
- explicit dependency on `dotnetCampus.Ipc` for the eventual pipe transport binding
|
||||
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPublicIpcContributor
|
||||
{
|
||||
void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/IPluginWorker.cs
Normal file
12
LanMountainDesktop.PluginSdk/IPluginWorker.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginWorker
|
||||
{
|
||||
void ConfigureServices(IPluginWorkerContext context, IServiceCollection services);
|
||||
|
||||
Task StartAsync(IPluginWorkerContext context, IServiceProvider services, CancellationToken cancellationToken = default);
|
||||
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
26
LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs
Normal file
26
LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginWorkerContext
|
||||
{
|
||||
string PluginId { get; }
|
||||
|
||||
PluginManifest Manifest { get; }
|
||||
|
||||
PluginRuntimeMode RuntimeMode { get; }
|
||||
|
||||
string SessionId { get; }
|
||||
|
||||
string HostPipeName { get; }
|
||||
|
||||
string ProtocolVersion { get; }
|
||||
|
||||
string PluginDirectory { get; }
|
||||
|
||||
string DataDirectory { get; }
|
||||
|
||||
IReadOnlyList<PluginCapabilityDeclaration> GrantedCapabilities { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> StartupProperties { get; }
|
||||
}
|
||||
@@ -25,7 +25,10 @@
|
||||
<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>
|
||||
|
||||
@@ -10,7 +10,8 @@ public sealed record PluginManifest(
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null,
|
||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null,
|
||||
PluginRuntimeConfiguration? Runtime = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
@@ -56,9 +57,13 @@ public sealed record PluginManifest(
|
||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||
}
|
||||
|
||||
public PluginRuntimeMode RuntimeMode =>
|
||||
PluginRuntimeModes.TryParse(Runtime?.Mode, out var mode) ? mode : PluginRuntimeMode.InProcess;
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
||||
var normalizedRuntime = (Runtime ?? new PluginRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
@@ -68,7 +73,8 @@ public sealed record PluginManifest(
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
||||
SharedContracts = normalizedSharedContracts
|
||||
SharedContracts = normalizedSharedContracts,
|
||||
Runtime = normalizedRuntime
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPublicIpcServiceDescriptor(
|
||||
Type ContractType,
|
||||
object Implementation,
|
||||
string? ObjectId,
|
||||
string[] NotifyIds);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPublicIpcServiceRegistration(
|
||||
Type ContractType,
|
||||
string? ObjectId,
|
||||
string[] NotifyIds);
|
||||
15
LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs
Normal file
15
LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginRuntimeConfiguration(string Mode = PluginRuntimeModes.InProcess)
|
||||
{
|
||||
public PluginRuntimeMode RuntimeMode =>
|
||||
PluginRuntimeModes.TryParse(Mode, out var mode) ? mode : PluginRuntimeMode.InProcess;
|
||||
|
||||
internal PluginRuntimeConfiguration NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
return this with
|
||||
{
|
||||
Mode = PluginRuntimeModes.NormalizeManifestValue(Mode, manifestPath)
|
||||
};
|
||||
}
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginRuntimeMode
|
||||
{
|
||||
InProcess = 0,
|
||||
IsolatedBackground = 1,
|
||||
IsolatedWindow = 2
|
||||
}
|
||||
58
LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs
Normal file
58
LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginRuntimeModes
|
||||
{
|
||||
public const string InProcess = "in-proc";
|
||||
public const string IsolatedBackground = "isolated-background";
|
||||
public const string IsolatedWindow = "isolated-window";
|
||||
|
||||
public static bool TryParse(string? value, out PluginRuntimeMode mode)
|
||||
{
|
||||
switch (value?.Trim().ToLowerInvariant())
|
||||
{
|
||||
case null:
|
||||
case "":
|
||||
case InProcess:
|
||||
mode = PluginRuntimeMode.InProcess;
|
||||
return true;
|
||||
case IsolatedBackground:
|
||||
mode = PluginRuntimeMode.IsolatedBackground;
|
||||
return true;
|
||||
case IsolatedWindow:
|
||||
mode = PluginRuntimeMode.IsolatedWindow;
|
||||
return true;
|
||||
default:
|
||||
mode = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static PluginRuntimeMode Parse(string? value, string sourceName, string propertyName = "runtime.mode")
|
||||
{
|
||||
if (TryParse(value, out var mode))
|
||||
{
|
||||
return mode;
|
||||
}
|
||||
|
||||
var candidate = string.IsNullOrWhiteSpace(value) ? "<empty>" : value.Trim();
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{sourceName}' declares unsupported runtime mode '{candidate}' in '{propertyName}'. " +
|
||||
$"Supported values: '{InProcess}', '{IsolatedBackground}', '{IsolatedWindow}'.");
|
||||
}
|
||||
|
||||
public static string NormalizeManifestValue(string? value, string sourceName, string propertyName = "runtime.mode")
|
||||
{
|
||||
return ToManifestValue(Parse(value, sourceName, propertyName));
|
||||
}
|
||||
|
||||
public static string ToManifestValue(PluginRuntimeMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
PluginRuntimeMode.InProcess => InProcess,
|
||||
PluginRuntimeMode.IsolatedBackground => IsolatedBackground,
|
||||
PluginRuntimeMode.IsolatedWindow => IsolatedWindow,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported plugin runtime mode.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
@@ -112,6 +113,55 @@ 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
|
||||
|
||||
20
LanMountainDesktop.PluginSdk/PluginWorkerBase.cs
Normal file
20
LanMountainDesktop.PluginSdk/PluginWorkerBase.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginWorkerBase : IPluginWorker
|
||||
{
|
||||
public virtual void ConfigureServices(IPluginWorkerContext context, IServiceCollection services)
|
||||
{
|
||||
}
|
||||
|
||||
public virtual Task StartAsync(IPluginWorkerContext context, IServiceProvider services, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginWorkerEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
@@ -5,7 +5,9 @@ Official SDK package for LanMountainDesktop plugins.
|
||||
## Includes
|
||||
|
||||
- `IPlugin`/`PluginBase` entry abstractions
|
||||
- `IPluginWorker`/`PluginWorkerBase` worker-side entry abstractions for isolated background mode
|
||||
- `PluginManifest` and shared contract declarations
|
||||
- `runtime.mode` manifest support for `in-proc`, `isolated-background`, and `isolated-window`
|
||||
- desktop component registration extensions
|
||||
- plugin runtime context and host service abstractions
|
||||
- build-transitive packaging targets for `.laapp` output
|
||||
|
||||
@@ -22,3 +22,4 @@ Update `plugin.json` fields as needed before release:
|
||||
- `description`
|
||||
- `author`
|
||||
- `version`
|
||||
- `runtime.mode` (`in-proc` by default, `isolated-background` for phase-1 worker mode)
|
||||
|
||||
@@ -6,5 +6,8 @@
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "4.0.2",
|
||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||
"sharedContracts": []
|
||||
"sharedContracts": [],
|
||||
"runtime": {
|
||||
"mode": "in-proc"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
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 = rawValue.Split('+', 2, StringSplitOptions.TrimEntries)[0].Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? fallback
|
||||
: normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(rawValue)
|
||||
? fallback
|
||||
: rawValue.Trim();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
var json = File.ReadAllText(versionFilePath);
|
||||
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
|
||||
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info = new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(parsedInfo.Version),
|
||||
Codename = NormalizeCodename(parsedInfo.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ public enum StartupStage
|
||||
Initializing,
|
||||
LoadingSettings,
|
||||
LoadingPlugins,
|
||||
TrayReady,
|
||||
InitializingUI,
|
||||
ShellInitialized,
|
||||
BackgroundReady,
|
||||
DesktopVisible,
|
||||
ActivationRedirected,
|
||||
ActivationFailed,
|
||||
@@ -35,4 +37,10 @@ public static class LauncherIpcConstants
|
||||
public const string VersionEnvVar = "LMD_VERSION";
|
||||
|
||||
public const string CodenameEnvVar = "LMD_CODENAME";
|
||||
|
||||
public const string LaunchSourceOptionName = "launch-source";
|
||||
|
||||
public const string RestartParentPidOptionName = "restart-parent-pid";
|
||||
|
||||
public const string RestartPresentationOptionName = "restart-presentation";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public enum RestartPresentationMode
|
||||
{
|
||||
Foreground = 0,
|
||||
Minimized = 1,
|
||||
Tray = 2
|
||||
}
|
||||
|
||||
public static class LauncherRuntimeMetadata
|
||||
{
|
||||
public static string? GetOptionValue(string key, IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var longPrefix = $"--{key}";
|
||||
|
||||
for (var index = 0; index < args.Count; index++)
|
||||
{
|
||||
var argument = args[index];
|
||||
if (!argument.StartsWith(longPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(argument, longPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
return "true";
|
||||
}
|
||||
|
||||
if (argument.Length > longPrefix.Length && argument[longPrefix.Length] == '=')
|
||||
{
|
||||
return argument[(longPrefix.Length + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool HasOption(string key, IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(GetOptionValue(key, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetPackageRoot(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.PackageRootEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.PackageRootEnvVar, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetForwardedVersion(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.VersionEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.VersionEnvVar, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetForwardedCodename(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.CodenameEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.CodenameEnvVar, commandLineArgs));
|
||||
}
|
||||
|
||||
public static string? GetLaunchSource(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
return GetOptionValue(LauncherIpcConstants.LaunchSourceOptionName, commandLineArgs);
|
||||
}
|
||||
|
||||
public static int? GetLauncherProcessId(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
var rawValue = FirstNonEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar),
|
||||
GetOptionValue(LauncherIpcConstants.LauncherPidEnvVar, commandLineArgs));
|
||||
|
||||
return TryParsePositiveInt(rawValue);
|
||||
}
|
||||
|
||||
public static int? GetRestartParentProcessId(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
var rawValue = GetOptionValue(LauncherIpcConstants.RestartParentPidOptionName, commandLineArgs);
|
||||
return TryParsePositiveInt(rawValue);
|
||||
}
|
||||
|
||||
public static RestartPresentationMode? GetRestartPresentationMode(IReadOnlyList<string>? commandLineArgs = null)
|
||||
{
|
||||
var rawValue = GetOptionValue(LauncherIpcConstants.RestartPresentationOptionName, commandLineArgs);
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return NormalizeRestartPresentation(rawValue);
|
||||
}
|
||||
|
||||
public static string FormatRestartPresentation(RestartPresentationMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
RestartPresentationMode.Minimized => "minimized",
|
||||
RestartPresentationMode.Tray => "tray",
|
||||
_ => "foreground"
|
||||
};
|
||||
}
|
||||
|
||||
public static RestartPresentationMode NormalizeRestartPresentation(string rawValue)
|
||||
{
|
||||
return rawValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"minimized" => RestartPresentationMode.Minimized,
|
||||
"tray" => RestartPresentationMode.Tray,
|
||||
_ => RestartPresentationMode.Foreground
|
||||
};
|
||||
}
|
||||
|
||||
private static int? TryParsePositiveInt(string? rawValue)
|
||||
{
|
||||
return int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue) &&
|
||||
parsedValue > 0
|
||||
? parsedValue
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicAppInfoService
|
||||
{
|
||||
PublicAppInfoSnapshot GetAppInfo();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicPluginCatalogService
|
||||
{
|
||||
PublicIpcCatalogSnapshot GetCatalog();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicShellControlService
|
||||
{
|
||||
Task<bool> ActivateMainWindowAsync();
|
||||
|
||||
Task<bool> OpenSettingsAsync(string? pageTag = null);
|
||||
|
||||
Task<bool> RestartAsync();
|
||||
|
||||
Task<bool> ExitAsync();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
|
||||
|
||||
public sealed record PublicIpcServiceRegistration(
|
||||
Type ContractType,
|
||||
Func<IServiceProvider, object> ImplementationFactory,
|
||||
string? ObjectId,
|
||||
string? PluginId,
|
||||
string[] NotifyIds);
|
||||
@@ -0,0 +1,83 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public interface IExternalIpcNotificationPublisher
|
||||
{
|
||||
Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
|
||||
where TPayload : class;
|
||||
}
|
||||
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicAppInfoSnapshot(
|
||||
string ApplicationName,
|
||||
string Version,
|
||||
string Codename,
|
||||
string PipeName,
|
||||
int ProcessId,
|
||||
DateTimeOffset StartedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicIpcCatalogSnapshot(
|
||||
PublicIpcServiceDescriptor[] Services,
|
||||
PublicPluginDescriptor[] Plugins,
|
||||
DateTimeOffset Timestamp);
|
||||
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
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);
|
||||
}
|
||||
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicIpcServiceDescriptor(
|
||||
string ContractTypeName,
|
||||
string ContractAssemblyName,
|
||||
string? ContractAssemblyQualifiedName,
|
||||
string? ObjectId,
|
||||
string? PluginId,
|
||||
bool IsBuiltIn,
|
||||
string[] NotifyIds);
|
||||
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicIpcSessionInfo(
|
||||
string PipeName,
|
||||
string ProtocolVersion,
|
||||
string[] Capabilities,
|
||||
DateTimeOffset StartedAt);
|
||||
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicPluginDescriptor(
|
||||
string PluginId,
|
||||
string DisplayName,
|
||||
string? Version,
|
||||
bool IsLoaded,
|
||||
bool IsEnabled);
|
||||
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# LanMountainDesktop.Shared.IPC
|
||||
|
||||
Public IPC abstractions and host/client helpers for LanMountainDesktop.
|
||||
120
LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
Normal file
120
LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
48
LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs
Normal file
48
LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginManifestRuntimeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_WhenRuntimeIsMissing_DefaultsToInProcess()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "plugin.runtime.default",
|
||||
"name": "Runtime Default",
|
||||
"entranceAssembly": "Plugin.dll"
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
var manifest = PluginManifest.Load(stream, "plugin.json");
|
||||
|
||||
Assert.NotNull(manifest.Runtime);
|
||||
Assert.Equal(PluginRuntimeModes.InProcess, manifest.Runtime!.Mode);
|
||||
Assert.Equal(PluginRuntimeMode.InProcess, manifest.RuntimeMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenRuntimeModeIsInvalid_ThrowsHelpfulError()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "plugin.runtime.invalid",
|
||||
"name": "Runtime Invalid",
|
||||
"entranceAssembly": "Plugin.dll",
|
||||
"runtime": {
|
||||
"mode": "shared-worker"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => PluginManifest.Load(stream, "plugin.json"));
|
||||
|
||||
Assert.Contains("runtime.mode", ex.Message);
|
||||
Assert.Contains("shared-worker", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SettingsWindowPlacementHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveWorkingArea_PrefersReferenceScreen()
|
||||
{
|
||||
var referenceArea = new PixelRect(1920, 0, 2560, 1440);
|
||||
var primaryArea = new PixelRect(0, 0, 1920, 1080);
|
||||
|
||||
var result = SettingsWindowPlacementHelper.ResolveWorkingArea(
|
||||
referenceArea,
|
||||
primaryArea,
|
||||
fallbackWindowWidth: 1120,
|
||||
fallbackWindowHeight: 760);
|
||||
|
||||
Assert.Equal(referenceArea, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWorkingArea_FallsBackToPrimaryScreenWhenReferenceIsMissing()
|
||||
{
|
||||
var primaryArea = new PixelRect(0, 0, 1920, 1080);
|
||||
|
||||
var result = SettingsWindowPlacementHelper.ResolveWorkingArea(
|
||||
referenceWorkingArea: null,
|
||||
primaryWorkingArea: primaryArea,
|
||||
fallbackWindowWidth: 1120,
|
||||
fallbackWindowHeight: 760);
|
||||
|
||||
Assert.Equal(primaryArea, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateCenteredPosition_ReturnsCenteredPointInsideWorkingArea()
|
||||
{
|
||||
var workingArea = new PixelRect(1920, 40, 2560, 1400);
|
||||
|
||||
var result = SettingsWindowPlacementHelper.CalculateCenteredPosition(
|
||||
workingArea,
|
||||
windowWidth: 1120,
|
||||
windowHeight: 760);
|
||||
|
||||
Assert.Equal(new PixelPoint(2640, 360), result);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
<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" />
|
||||
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||
|
||||
@@ -20,10 +20,13 @@ using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.ExternalIpc;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Services.Loading;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
@@ -55,6 +58,10 @@ public partial class App : Application
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
|
||||
private readonly string _launchSource = LauncherRuntimeMetadata.GetLaunchSource(Environment.GetCommandLineArgs()) ?? "normal";
|
||||
private readonly RestartPresentationMode? _requestedRestartPresentationMode =
|
||||
LauncherRuntimeMetadata.GetRestartPresentationMode(Environment.GetCommandLineArgs());
|
||||
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||
private ISettingsWindowService? _settingsWindowService;
|
||||
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||
@@ -63,19 +70,14 @@ public partial class App : Application
|
||||
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||
private ShutdownIntent _shutdownIntent;
|
||||
|
||||
private TrayIcon? _trayIcon;
|
||||
private NativeMenuItem? _trayShowDesktopMenuItem;
|
||||
private NativeMenuItem? _traySettingsMenuItem;
|
||||
private NativeMenuItem? _trayComponentLibraryMenuItem;
|
||||
private NativeMenuItem? _trayRestartMenuItem;
|
||||
private NativeMenuItem? _trayExitMenuItem;
|
||||
private DesktopTrayService? _desktopTrayService;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private MainWindow? _mainWindow;
|
||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
private LauncherIpcClient? _launcherIpcClient;
|
||||
private PublicIpcHostService? _publicIpcHostService;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
@@ -104,6 +106,15 @@ public partial class App : Application
|
||||
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
|
||||
internal INotificationService? NotificationService => _notificationService;
|
||||
internal RestartPresentationMode GetCurrentRestartPresentationMode()
|
||||
{
|
||||
return _desktopShellState switch
|
||||
{
|
||||
DesktopShellState.TrayOnly => RestartPresentationMode.Tray,
|
||||
DesktopShellState.MinimizedToTaskbar => RestartPresentationMode.Minimized,
|
||||
_ => RestartPresentationMode.Foreground
|
||||
};
|
||||
}
|
||||
|
||||
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
|
||||
{
|
||||
@@ -113,8 +124,8 @@ public partial class App : Application
|
||||
$"Opening settings window. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
|
||||
_settingsWindowService?.Open(new SettingsWindowOpenRequest(
|
||||
Source: source,
|
||||
Owner: _mainWindow is { IsVisible: true } ? _mainWindow : null,
|
||||
PageId: pageTag));
|
||||
PageId: pageTag,
|
||||
ScreenReferenceWindow: _mainWindow is { IsVisible: true } ? _mainWindow : null));
|
||||
}
|
||||
|
||||
public App()
|
||||
@@ -160,6 +171,7 @@ public partial class App : Application
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePublicIpc();
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
@@ -173,34 +185,24 @@ public partial class App : Application
|
||||
|
||||
private async Task InitializeLauncherIpcAsync()
|
||||
{
|
||||
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
||||
if (_loadingStateManager is not null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_launcherIpcClient = new LauncherIpcClient();
|
||||
var connected = await _launcherIpcClient.ConnectAsync();
|
||||
if (!connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
|
||||
|
||||
bool hadBufferedMessages;
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0;
|
||||
}
|
||||
|
||||
await FlushPendingLauncherProgressAsync();
|
||||
|
||||
_loadingStateManager = new LoadingStateManager();
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _publicIpcHostService);
|
||||
_loadingStateReporter.Start();
|
||||
|
||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services.");
|
||||
_loadingStateManager.StartItem("system.init", "Launcher IPC connected.");
|
||||
_loadingStateManager.StartItem("system.init", "Public IPC host ready.");
|
||||
await FlushPendingLauncherProgressAsync();
|
||||
|
||||
if (!hadBufferedMessages)
|
||||
{
|
||||
@@ -238,8 +240,8 @@ public partial class App : Application
|
||||
|
||||
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
|
||||
{
|
||||
var ipcClient = _launcherIpcClient;
|
||||
if (ipcClient is null || !ipcClient.IsConnected)
|
||||
var publicIpcHostService = _publicIpcHostService;
|
||||
if (publicIpcHostService is null)
|
||||
{
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
@@ -250,13 +252,13 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
_ = SendLauncherProgressAsync(ipcClient, message, logSuccess);
|
||||
_ = SendLauncherProgressAsync(publicIpcHostService, message, logSuccess);
|
||||
}
|
||||
|
||||
private async Task FlushPendingLauncherProgressAsync()
|
||||
{
|
||||
var ipcClient = _launcherIpcClient;
|
||||
if (ipcClient is null || !ipcClient.IsConnected)
|
||||
var publicIpcHostService = _publicIpcHostService;
|
||||
if (publicIpcHostService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -270,15 +272,15 @@ public partial class App : Application
|
||||
|
||||
foreach (var pendingMessage in pendingMessages)
|
||||
{
|
||||
await SendLauncherProgressAsync(ipcClient, pendingMessage, logSuccess: false);
|
||||
await SendLauncherProgressAsync(publicIpcHostService, pendingMessage, logSuccess: false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendLauncherProgressAsync(LauncherIpcClient ipcClient, StartupProgressMessage message, bool logSuccess)
|
||||
private async Task SendLauncherProgressAsync(PublicIpcHostService publicIpcHostService, StartupProgressMessage message, bool logSuccess)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ipcClient.ReportProgressAsync(message);
|
||||
await publicIpcHostService.PublishStartupProgressAsync(message);
|
||||
if (logSuccess)
|
||||
{
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
|
||||
@@ -463,7 +465,7 @@ public partial class App : Application
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
|
||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade, _publicIpcHostService);
|
||||
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
|
||||
_pluginRuntimeService.LoadInstalledPlugins();
|
||||
}
|
||||
@@ -475,128 +477,90 @@ public partial class App : Application
|
||||
|
||||
private void InitializeTrayIcon()
|
||||
{
|
||||
try
|
||||
EnsureDesktopTrayService();
|
||||
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
|
||||
if (_trayInitialized)
|
||||
{
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
_trayShowDesktopMenuItem = new NativeMenuItem();
|
||||
_trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
|
||||
|
||||
_traySettingsMenuItem = new NativeMenuItem();
|
||||
_traySettingsMenuItem.Click += OnTraySettingsClick;
|
||||
|
||||
_trayComponentLibraryMenuItem = new NativeMenuItem();
|
||||
_trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
|
||||
|
||||
_trayRestartMenuItem = new NativeMenuItem();
|
||||
_trayRestartMenuItem.Click += OnTrayRestartClick;
|
||||
|
||||
_trayExitMenuItem = new NativeMenuItem();
|
||||
_trayExitMenuItem.Click += OnTrayExitClick;
|
||||
|
||||
var trayMenu = new NativeMenu();
|
||||
trayMenu.Items.Add(_trayShowDesktopMenuItem);
|
||||
trayMenu.Items.Add(_traySettingsMenuItem);
|
||||
trayMenu.Items.Add(_trayComponentLibraryMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_trayRestartMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_trayExitMenuItem);
|
||||
|
||||
_trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = _appLogoService.CreateTrayIcon(),
|
||||
Menu = trayMenu,
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
TrayIcon.SetIcons(this, [_trayIcon]);
|
||||
}
|
||||
|
||||
RefreshTrayIconContent();
|
||||
_trayInitialized = true;
|
||||
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
|
||||
AppLogger.Info("TrayIcon", $"Tray initialized successfully. Pid={Environment.ProcessId}.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trayInitialized = false;
|
||||
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
|
||||
}
|
||||
|
||||
AppLogger.Warn("TrayIcon", "Tray initialization did not reach the ready state.");
|
||||
}
|
||||
|
||||
private void RefreshTrayIconContent()
|
||||
{
|
||||
if (_trayIcon is not null)
|
||||
{
|
||||
_trayIcon.IsVisible = true;
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
_trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
|
||||
if (_trayShowDesktopMenuItem is not null)
|
||||
{
|
||||
_trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
|
||||
}
|
||||
|
||||
if (_traySettingsMenuItem is not null)
|
||||
{
|
||||
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
|
||||
}
|
||||
|
||||
RefreshFusedDesktopMenuItemVisibility();
|
||||
|
||||
if (_trayRestartMenuItem is not null)
|
||||
{
|
||||
_trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
|
||||
}
|
||||
|
||||
if (_trayExitMenuItem is not null)
|
||||
{
|
||||
_trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
|
||||
}
|
||||
EnsureDesktopTrayService();
|
||||
_desktopTrayService?.Refresh("RefreshTrayContent");
|
||||
_trayInitialized = _desktopTrayService?.IsReady == true;
|
||||
}
|
||||
|
||||
private void RefreshFusedDesktopMenuItemVisibility()
|
||||
{
|
||||
if (_trayComponentLibraryMenuItem is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_trayComponentLibraryMenuItem.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
|
||||
|
||||
if (_trayComponentLibraryMenuItem.IsVisible)
|
||||
{
|
||||
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
|
||||
}
|
||||
RefreshTrayIconContent();
|
||||
}
|
||||
|
||||
private void DisposeTrayIcon()
|
||||
{
|
||||
if (_trayIcon is null)
|
||||
_desktopTrayService?.Dispose();
|
||||
_trayInitialized = false;
|
||||
}
|
||||
|
||||
private void EnsureDesktopTrayService()
|
||||
{
|
||||
if (_desktopTrayService is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
_desktopTrayService = new DesktopTrayService(
|
||||
this,
|
||||
_appLogoService,
|
||||
L,
|
||||
ShouldShowTrayComponentLibraryMenuItem,
|
||||
OnTrayShowDesktopClick,
|
||||
OnTraySettingsClick,
|
||||
OnTrayComponentLibraryClick,
|
||||
OnTrayRestartClick,
|
||||
OnTrayExitClick);
|
||||
_desktopTrayService.StateChanged += OnTrayAvailabilityStateChanged;
|
||||
}
|
||||
|
||||
private bool EnsureTrayReady(string reason)
|
||||
{
|
||||
EnsureDesktopTrayService();
|
||||
var ready = _desktopTrayService?.EnsureReady(reason) == true;
|
||||
_trayInitialized = ready;
|
||||
if (ready)
|
||||
{
|
||||
_trayIcon.IsVisible = false;
|
||||
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
private void OnTrayAvailabilityStateChanged(TrayAvailabilityState state)
|
||||
{
|
||||
_trayInitialized = state == TrayAvailabilityState.Ready;
|
||||
|
||||
if (state == TrayAvailabilityState.Failed && _desktopShellState == DesktopShellState.TrayOnly)
|
||||
{
|
||||
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldShowTrayComponentLibraryMenuItem()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
return appSnapshot.EnableFusedDesktop;
|
||||
}
|
||||
|
||||
private void EnsureSettingsWindowService()
|
||||
{
|
||||
_settingsPageRegistry ??= new SettingsPageRegistry(
|
||||
@@ -743,7 +707,7 @@ public partial class App : Application
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.PrepareEnterAnimation();
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar();
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
@@ -769,6 +733,7 @@ public partial class App : Application
|
||||
mainWindow.PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
@@ -877,6 +842,7 @@ public partial class App : Application
|
||||
if (themeChanged)
|
||||
{
|
||||
ApplyThemeFromSettings();
|
||||
RefreshTrayIconContent();
|
||||
}
|
||||
|
||||
if (languageChanged)
|
||||
@@ -903,7 +869,11 @@ public partial class App : Application
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
ApplyThemeFromSettings();
|
||||
RefreshTrayIconContent();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void ApplyAdaptiveThemeResources()
|
||||
@@ -1043,6 +1013,19 @@ public partial class App : Application
|
||||
_pluginRuntimeService = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_publicIpcHostService?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PublicIpc", "Failed to dispose public IPC host during shutdown.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publicIpcHostService = null;
|
||||
}
|
||||
|
||||
_settingsWindowService?.Close();
|
||||
if (_settingsPageRegistry is IDisposable disposableRegistry)
|
||||
{
|
||||
@@ -1098,7 +1081,7 @@ public partial class App : Application
|
||||
var mainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
ShowInTaskbar = true
|
||||
ShowInTaskbar = ShouldShowMainWindowInTaskbar()
|
||||
};
|
||||
|
||||
_mainWindowOpened = false;
|
||||
@@ -1136,18 +1119,56 @@ public partial class App : Application
|
||||
{
|
||||
mainWindow.Opened -= OnMainWindowOpened;
|
||||
_mainWindowOpened = true;
|
||||
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
|
||||
|
||||
if (TryApplyStartupPresentation(mainWindow))
|
||||
{
|
||||
AppLogger.Info(
|
||||
"App",
|
||||
$"Main window opened and startup presentation was applied. LaunchSource='{_launchSource}'; RestartPresentation='{_requestedRestartPresentationMode?.ToString() ?? "<none>"}'; ShellState='{_desktopShellState}'.");
|
||||
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
|
||||
_loadingStateReporter?.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"App",
|
||||
$"Main window opened. Reporting DesktopVisible. TrayInitialized={_trayInitialized}; ShellState='{_desktopShellState}'.");
|
||||
|
||||
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
|
||||
ReportStartupProgressSync(StartupStage.DesktopVisible, 100, "Desktop visible.");
|
||||
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
|
||||
_loadingStateReporter?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryApplyStartupPresentation(MainWindow mainWindow)
|
||||
{
|
||||
if (!string.Equals(_launchSource, "restart", StringComparison.OrdinalIgnoreCase) ||
|
||||
_requestedRestartPresentationMode is null ||
|
||||
_requestedRestartPresentationMode == RestartPresentationMode.Foreground)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (_requestedRestartPresentationMode)
|
||||
{
|
||||
case RestartPresentationMode.Minimized:
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "StartupRestartPresentation");
|
||||
ReportStartupProgressSync(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||
return true;
|
||||
|
||||
case RestartPresentationMode.Tray:
|
||||
HideMainWindowToTray(mainWindow, "StartupRestartPresentation");
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private MainWindow GetOrCreateMainWindow(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
string reason)
|
||||
@@ -1234,7 +1255,15 @@ public partial class App : Application
|
||||
|
||||
if (_shutdownIntent == ShutdownIntent.None)
|
||||
{
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
|
||||
if (EnsureTrayReady("MainWindowClosedUnexpected"))
|
||||
{
|
||||
_desktopTrayService?.StartWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowClosedUnexpectedWithoutTray");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,9 +1297,17 @@ public partial class App : Application
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!EnsureTrayReady($"HideToTray:{source}"))
|
||||
{
|
||||
RecoverFromTrayUnavailable(mainWindow, source);
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.ShowInTaskbar = false;
|
||||
mainWindow.Hide();
|
||||
_desktopTrayService?.StartWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||
@@ -1285,9 +1322,61 @@ public partial class App : Application
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to hide main window to tray. Source='{source}'.", ex);
|
||||
RecoverFromTrayUnavailable(mainWindow, source);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecoverFromTrayUnavailable(MainWindow mainWindow, string source)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"DesktopShell",
|
||||
$"Tray was unavailable. Recovering to a visible or taskbar-backed state instead of TrayOnly. Source='{source}'.");
|
||||
|
||||
var showInTaskbar = ShouldShowMainWindowInTaskbar();
|
||||
if (showInTaskbar)
|
||||
{
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TrayFallbackTaskbar:{source}");
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar fallback.");
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}");
|
||||
ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable.");
|
||||
}
|
||||
|
||||
private bool ShouldShowMainWindowInTaskbar()
|
||||
{
|
||||
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
|
||||
}
|
||||
|
||||
private void SetDesktopShellState(DesktopShellState state, string source)
|
||||
{
|
||||
if (_desktopShellState == state)
|
||||
@@ -1336,6 +1425,58 @@ public partial class App : Application
|
||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
return _localizationService.GetString(languageCode, key, fallback);
|
||||
}
|
||||
|
||||
internal bool TryActivateMainWindowFromExternalIpc(string source)
|
||||
{
|
||||
return RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||
}
|
||||
|
||||
private void InitializePublicIpc()
|
||||
{
|
||||
if (_publicIpcHostService is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
|
||||
_publicIpcHostService = new PublicIpcHostService();
|
||||
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
|
||||
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
|
||||
new PublicAppInfoService(_startupAt));
|
||||
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
|
||||
new PublicShellControlService());
|
||||
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
|
||||
new PublicPluginCatalogService(_publicIpcHostService));
|
||||
_publicIpcHostService.Start();
|
||||
AppLogger.Info(
|
||||
"PublicIpc",
|
||||
$"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'; Version='{versionInfo.Version}'; Codename='{versionInfo.Codename}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PublicIpc", "Failed to initialize public IPC host.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<PublicPluginDescriptor> BuildPublicPluginDescriptors()
|
||||
{
|
||||
var runtime = _pluginRuntimeService;
|
||||
if (runtime is null)
|
||||
{
|
||||
return Array.Empty<PublicPluginDescriptor>();
|
||||
}
|
||||
|
||||
return runtime.Catalog
|
||||
.Select(entry => new PublicPluginDescriptor(
|
||||
entry.Manifest.Id,
|
||||
entry.Manifest.Name,
|
||||
entry.Manifest.Version,
|
||||
entry.IsLoaded,
|
||||
entry.IsEnabled))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
@@ -31,6 +31,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -154,6 +154,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool EnableSlideTransition { get; set; } = false;
|
||||
|
||||
public bool ShowInTaskbar { get; set; } = false;
|
||||
|
||||
public bool EnableFusedDesktop { get; set; } = false;
|
||||
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class Program
|
||||
AppLogger.Initialize();
|
||||
DevPluginOptions.Parse(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
if (!singleInstance.IsPrimaryInstance)
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
|
||||
|
||||
public static bool TryRestartApplication()
|
||||
{
|
||||
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
@@ -42,19 +39,34 @@ public static class AppRestartService
|
||||
public static ProcessStartInfo? CreateRestartStartInfo(
|
||||
string[]? commandLineArgs = null,
|
||||
string? processPath = null,
|
||||
string? entryAssemblyLocation = null)
|
||||
string? entryAssemblyLocation = null,
|
||||
RestartPresentationMode? restartPresentationMode = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingPath(
|
||||
var resolvedProcessPath = NormalizeExistingFile(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingFile(
|
||||
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
|
||||
var normalizedRestartPresentation = restartPresentationMode
|
||||
?? LauncherRuntimeMetadata.GetRestartPresentationMode(args)
|
||||
?? RestartPresentationMode.Foreground;
|
||||
|
||||
var launcherStartInfo = TryCreateLauncherStartInfo(
|
||||
args,
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
normalizedRestartPresentation);
|
||||
if (launcherStartInfo is not null)
|
||||
{
|
||||
return launcherStartInfo;
|
||||
}
|
||||
|
||||
if (IsDotnetHost(resolvedProcessPath))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
resolvedProcessPath!,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
|
||||
@@ -62,7 +74,8 @@ public static class AppRestartService
|
||||
return CreateExecutableStartInfo(
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
|
||||
@@ -71,7 +84,8 @@ public static class AppRestartService
|
||||
return CreateDotnetStartInfo(
|
||||
"dotnet",
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -80,22 +94,20 @@ public static class AppRestartService
|
||||
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLineArgs);
|
||||
return LauncherRuntimeMetadata.GetRestartParentProcessId(commandLineArgs);
|
||||
}
|
||||
|
||||
foreach (var argument in commandLineArgs)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(argument, out var processId))
|
||||
{
|
||||
return processId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
public static RestartPresentationMode? TryGetRestartPresentationMode(IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLineArgs);
|
||||
return LauncherRuntimeMetadata.GetRestartPresentationMode(commandLineArgs);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateExecutableStartInfo(
|
||||
string executablePath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -104,18 +116,17 @@ public static class AppRestartService
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
|
||||
var args = new System.Text.StringBuilder();
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
var arguments = new StringBuilder();
|
||||
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
|
||||
startInfo.Arguments = arguments.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo? CreateDotnetStartInfo(
|
||||
string dotnetHostPath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
|
||||
{
|
||||
@@ -129,51 +140,182 @@ public static class AppRestartService
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
// UseShellExecute=true 时使用 Arguments 字符串
|
||||
var args = new System.Text.StringBuilder();
|
||||
args.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
var arguments = new StringBuilder();
|
||||
arguments.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
|
||||
startInfo.Arguments = arguments.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
|
||||
private static ProcessStartInfo? TryCreateLauncherStartInfo(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
var launcherPath = ResolveLauncherPath(commandLineArgs, processPath, entryAssemblyPath);
|
||||
if (string.IsNullOrWhiteSpace(launcherPath))
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
return null;
|
||||
}
|
||||
|
||||
var arguments = new StringBuilder();
|
||||
AppendFilteredArguments(arguments, commandLineArgs);
|
||||
AppendRestartArguments(arguments, restartPresentationMode);
|
||||
|
||||
return new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = arguments.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveLauncherPath(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath)
|
||||
{
|
||||
var launcherFileName = OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.Launcher.exe"
|
||||
: "LanMountainDesktop.Launcher";
|
||||
|
||||
foreach (var packageRootCandidate in GetPackageRootCandidates(commandLineArgs, processPath, entryAssemblyPath))
|
||||
{
|
||||
var normalizedRoot = NormalizeExistingDirectory(packageRootCandidate);
|
||||
if (string.IsNullOrWhiteSpace(normalizedRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(commandLineArgs[i]);
|
||||
var directCandidate = Path.Combine(normalizedRoot, launcherFileName);
|
||||
if (File.Exists(directCandidate))
|
||||
{
|
||||
return directCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
private static IEnumerable<string?> GetPackageRootCandidates(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
yield return LauncherRuntimeMetadata.GetPackageRoot(commandLineArgs);
|
||||
|
||||
foreach (var path in new[] { entryAssemblyPath, processPath, AppContext.BaseDirectory })
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
var directory = GetDirectoryFromPath(path);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append(QuoteArgument(commandLineArgs[i]));
|
||||
yield return directory;
|
||||
yield return Path.GetDirectoryName(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
|
||||
private static string? GetDirectoryFromPath(string? path)
|
||||
{
|
||||
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (Directory.Exists(fullPath))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
return File.Exists(fullPath)
|
||||
? Path.GetDirectoryName(fullPath)
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
|
||||
private static void AppendForwardedArguments(
|
||||
StringBuilder builder,
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
AppendFilteredArguments(builder, commandLineArgs);
|
||||
AppendRestartArguments(builder, restartPresentationMode);
|
||||
}
|
||||
|
||||
private static void AppendFilteredArguments(StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var index = 1; index < commandLineArgs.Count; index++)
|
||||
{
|
||||
if (ShouldSkipArgument(commandLineArgs, ref index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append(QuoteArgument(commandLineArgs[index]));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldSkipArgument(IReadOnlyList<string> commandLineArgs, ref int index)
|
||||
{
|
||||
var argument = commandLineArgs[index];
|
||||
if (!argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = argument[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
key = key[..equalsIndex];
|
||||
}
|
||||
|
||||
var shouldSkip = string.Equals(key, LauncherIpcConstants.LaunchSourceOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.RestartParentPidOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.RestartPresentationOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.LauncherPidEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.PackageRootEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.VersionEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.CodenameEnvVar, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (shouldSkip &&
|
||||
equalsIndex < 0 &&
|
||||
index + 1 < commandLineArgs.Count &&
|
||||
!commandLineArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
return shouldSkip;
|
||||
}
|
||||
|
||||
private static void AppendRestartArguments(StringBuilder builder, RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append($"--{LauncherIpcConstants.LaunchSourceOptionName}=restart");
|
||||
builder.Append($" --{LauncherIpcConstants.RestartParentPidOptionName}={Environment.ProcessId}");
|
||||
builder.Append(
|
||||
$" --{LauncherIpcConstants.RestartPresentationOptionName}={LauncherRuntimeMetadata.FormatRestartPresentation(restartPresentationMode)}");
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
@@ -188,7 +330,7 @@ public static class AppRestartService
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var builder = new StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
@@ -206,21 +348,7 @@ public static class AppRestartService
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
|
||||
{
|
||||
processId = 0;
|
||||
if (string.IsNullOrWhiteSpace(argument) ||
|
||||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(
|
||||
argument[RestartParentPidArgumentPrefix.Length..],
|
||||
out processId) && processId > 0;
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingPath(string? path)
|
||||
private static string? NormalizeExistingFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
@@ -238,6 +366,24 @@ public static class AppRestartService
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return Directory.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDotnetHost(string? processPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(processPath))
|
||||
|
||||
274
LanMountainDesktop/Services/DesktopTrayService.cs
Normal file
274
LanMountainDesktop/Services/DesktopTrayService.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
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;
|
||||
|
||||
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 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()
|
||||
{
|
||||
StopWatchdog();
|
||||
|
||||
try
|
||||
{
|
||||
if (_trayIcon is not null)
|
||||
{
|
||||
_trayIcon.IsVisible = false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
SetState(TrayAvailabilityState.Unavailable, "Dispose");
|
||||
}
|
||||
|
||||
private void OnWatchdogTick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (State == TrayAvailabilityState.Unavailable || State == TrayAvailabilityState.Failed)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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<bool> ActivateMainWindowAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.TryActivateMainWindowFromExternalIpc("PublicIpc") == true;
|
||||
}).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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
@@ -105,7 +106,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
|
||||
var launchArgs = startInfo?.Arguments ?? "";
|
||||
|
||||
@@ -121,7 +124,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
|
||||
Process.Start(helperStartInfo);
|
||||
|
||||
var app = Application.Current as App;
|
||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
||||
|
||||
return TryExit(request);
|
||||
@@ -129,7 +131,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
|
||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
if (startInfo is null)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
@@ -139,7 +143,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
var app = Application.Current as App;
|
||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
||||
var exitRequest = request is null
|
||||
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
|
||||
|
||||
@@ -7,9 +7,7 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
namespace LanMountainDesktop.Services.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
|
||||
/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
|
||||
/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
|
||||
/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。
|
||||
/// </summary>
|
||||
public class LauncherIpcClient : IDisposable
|
||||
{
|
||||
@@ -18,23 +16,14 @@ public class LauncherIpcClient : IDisposable
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
private NamedPipeClientStream? _pipeClient;
|
||||
private bool _isConnected;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否已连接到 Launcher
|
||||
/// </summary>
|
||||
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
|
||||
|
||||
/// <summary>
|
||||
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||
/// </summary>
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 连接到 Launcher 的 IPC 服务端
|
||||
/// </summary>
|
||||
public async Task<bool> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -50,7 +39,6 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Launcher 可能没有启动 IPC 服务端,这是正常的
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -60,24 +48,20 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告启动进度(在同一连接上可多次调用)
|
||||
/// </summary>
|
||||
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||
{
|
||||
if (!_isConnected || _pipeClient?.IsConnected != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(message, StartupProgressJsonOptions);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// 长度前缀协议:[4字节长度][消息正文]
|
||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||
Debug.Assert(lengthPrefix.Length == LengthPrefixSize);
|
||||
|
||||
// 加锁保证单条消息的长度前缀和正文原子写入
|
||||
lock (_writeLock)
|
||||
{
|
||||
_pipeClient.Write(lengthPrefix, 0, LengthPrefixSize);
|
||||
@@ -85,12 +69,10 @@ public class LauncherIpcClient : IDisposable
|
||||
_pipeClient.Flush();
|
||||
}
|
||||
|
||||
// 将同步写入包装为已完成的 Task
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 管道断开
|
||||
_isConnected = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -100,30 +82,9 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否从 Launcher 启动
|
||||
/// 优先检查环境变量,回退到命令行参数(UseShellExecute=true 时环境变量仍可继承,
|
||||
/// 命令行参数作为备选确保兼容性)
|
||||
/// </summary>
|
||||
public static bool IsLaunchedByLauncher()
|
||||
{
|
||||
// 优先检查环境变量
|
||||
if (!string.IsNullOrEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>)
|
||||
foreach (var arg in Environment.GetCommandLineArgs())
|
||||
{
|
||||
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return LauncherRuntimeMetadata.GetLauncherProcessId(Environment.GetCommandLineArgs()) is not null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -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 LauncherIpcClient? _ipcClient;
|
||||
private readonly IExternalIpcNotificationPublisher? _notificationPublisher;
|
||||
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,
|
||||
LauncherIpcClient? ipcClient = null)
|
||||
IExternalIpcNotificationPublisher? notificationPublisher = null)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
_ipcClient = ipcClient;
|
||||
_notificationPublisher = notificationPublisher;
|
||||
|
||||
// 创建定时上报定时器
|
||||
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
|
||||
@@ -80,7 +80,7 @@ public class LoadingStateReporter : IDisposable
|
||||
/// </summary>
|
||||
public async Task ReportImmediatelyAsync()
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == 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 || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == 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 || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == 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 || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == null) return;
|
||||
|
||||
var fullMessage = string.IsNullOrEmpty(details)
|
||||
? errorMessage
|
||||
@@ -280,7 +280,7 @@ public class LoadingStateReporter : IDisposable
|
||||
/// </summary>
|
||||
private async Task SendMessageAsync(DetailedProgressMessage message)
|
||||
{
|
||||
if (_ipcClient == null) return;
|
||||
if (_notificationPublisher == null) return;
|
||||
|
||||
// 检查最小上报间隔
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -293,15 +293,15 @@ public class LoadingStateReporter : IDisposable
|
||||
try
|
||||
{
|
||||
// 转换为 StartupProgressMessage 以保持兼容性
|
||||
var baseMessage = new StartupProgressMessage
|
||||
var loadingStateMessage = _manager.GetLoadingStateMessage() with
|
||||
{
|
||||
Stage = message.Stage,
|
||||
ProgressPercent = message.ProgressPercent,
|
||||
OverallProgressPercent = message.ProgressPercent,
|
||||
Message = FormatMessage(message),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _ipcClient.ReportProgressAsync(baseMessage);
|
||||
|
||||
await _notificationPublisher.NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, loadingStateMessage);
|
||||
_lastReportTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user