mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
5 Commits
5af7ac8b56
...
v0.8.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e20462ac2b | ||
|
|
aa7c118d13 | ||
|
|
f51ec309a6 | ||
|
|
9224c9a33a | ||
|
|
703ed7b48a |
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 相关的测试
|
||||
@@ -0,0 +1,8 @@
|
||||
# Launcher OOBE and Elevation Hardening Checklist
|
||||
|
||||
- [ ] New install shows OOBE once.
|
||||
- [ ] Same-user reinstall does not show OOBE again.
|
||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
||||
- [ ] Default plugin install does not request UAC.
|
||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Launcher OOBE and Elevation Hardening Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Stabilize the launcher startup path so that:
|
||||
|
||||
- OOBE does not reappear for the same Windows user after reinstall/upgrade.
|
||||
- Normal startup, OOBE, update checks, incremental downloads, and default plugin installs do not trigger unexpected UAC prompts.
|
||||
- Only the approved elevation paths remain allowed.
|
||||
|
||||
## Scope
|
||||
|
||||
- Launcher OOBE state handling
|
||||
- launch source classification
|
||||
- elevation boundary cleanup
|
||||
- plugin install default behavior
|
||||
- diagnostic logging and troubleshooting guidance
|
||||
|
||||
## Behavior
|
||||
|
||||
- OOBE state is stored as a per-user truth source at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- `first_run_completed` is treated as a legacy compatibility marker only.
|
||||
- `launchSource` values are treated as:
|
||||
- `normal`
|
||||
- `postinstall`
|
||||
- `apply-update`
|
||||
- `plugin-install`
|
||||
- `debug-preview`
|
||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to:
|
||||
- the installer itself
|
||||
- full installer update application
|
||||
- user-confirmed legacy uninstall
|
||||
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Same-user reinstall does not re-enter OOBE.
|
||||
- Missing or damaged OOBE state does not silently bounce the user back into OOBE loops.
|
||||
- Default plugin installation path never triggers surprise UAC.
|
||||
- Logs can explain why OOBE was shown or suppressed and why elevation was or was not requested.
|
||||
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Launcher OOBE and Elevation Hardening Tasks
|
||||
|
||||
- [ ] Move OOBE state to a single per-user JSON source.
|
||||
- [ ] Treat `first_run_completed` as legacy migration-only state.
|
||||
- [ ] Add explicit `launchSource` handling for startup and maintenance flows.
|
||||
- [ ] Suppress auto-OOBE for maintenance and elevated launch contexts.
|
||||
- [ ] Remove default elevation from plugin installation into the user data scope.
|
||||
- [ ] Add structured diagnostics for OOBE decisions and elevation reasons.
|
||||
- [ ] Update launcher docs and troubleshooting guidance.
|
||||
@@ -6,3 +6,6 @@
|
||||
- [x] Legacy plugin install arguments still execute.
|
||||
- [x] OOBE and splash are implemented as separate windows.
|
||||
- [x] Update and rollback logic use version directory markers.
|
||||
|
||||
- [ ] Treat `first_run_completed` as legacy-only compatibility data.
|
||||
- [ ] Keep the authoritative OOBE state in `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
|
||||
@@ -52,3 +52,9 @@ Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
|
||||
|
||||
- `IOobeStep` for future multi-step OOBE
|
||||
- `ISplashStageReporter` for future startup progress visualization
|
||||
|
||||
## Compatibility Addendum
|
||||
|
||||
- The current production OOBE state format is a per-user JSON file at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- `first_run_completed` remains legacy compatibility data only.
|
||||
- Same-user reinstall or upgrade should not re-enter OOBE.
|
||||
|
||||
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 行为
|
||||
|
||||
@@ -13,10 +13,15 @@ public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
// 初始化日志记录器
|
||||
Logger.Initialize();
|
||||
Logger.Info("Launcher starting...");
|
||||
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
Logger.Info(
|
||||
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
|
||||
$"IsPreview={context.IsPreviewCommand}; IsDebugMode={context.IsDebugMode}; " +
|
||||
$"LaunchSource='{context.LaunchSource}'; IsElevated={execution.IsElevated}; " +
|
||||
$"UserSid='{execution.UserSid ?? string.Empty}'; ExplicitAppRoot='{context.ExplicitAppRoot ?? "<none>"}'.");
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
@@ -24,41 +29,31 @@ public partial class App : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
Logger.Info(
|
||||
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
|
||||
|
||||
// 调试模式:显示开发调试窗口
|
||||
if (context.IsDebugMode)
|
||||
{
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
|
||||
// 调试模式下不自动启动正常流程,由开发者通过调试窗口控制
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理各界面的预览命令
|
||||
if (HandlePreviewCommand(context, desktop))
|
||||
{
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// apply-update 模式:显示 UpdateWindow,执行增量更新 + 插件升级
|
||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 先显示窗口,再启动后台任务
|
||||
var updateWindow = new UpdateWindow();
|
||||
updateWindow.Show();
|
||||
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 先显示 Splash 窗口,确保应用程序不会立即退出
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.Show();
|
||||
|
||||
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
}
|
||||
@@ -66,156 +61,128 @@ public partial class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理界面预览命令
|
||||
/// </summary>
|
||||
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var command = context.Command.ToLowerInvariant();
|
||||
|
||||
switch (command)
|
||||
switch (context.Command.ToLowerInvariant())
|
||||
{
|
||||
case "preview-splash":
|
||||
Console.WriteLine("[Launcher] Preview mode: SplashWindow");
|
||||
{
|
||||
Logger.Info("Preview command: splash.");
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.SetDebugMode(true);
|
||||
splashWindow.Show();
|
||||
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
||||
return true;
|
||||
|
||||
}
|
||||
case "preview-error":
|
||||
Console.WriteLine("[Launcher] Preview mode: ErrorWindow");
|
||||
{
|
||||
Logger.Info("Preview command: error.");
|
||||
var errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
|
||||
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
|
||||
errorWindow.Show();
|
||||
_ = WaitForWindowCloseAsync(desktop, errorWindow);
|
||||
return true;
|
||||
|
||||
}
|
||||
case "preview-update":
|
||||
Console.WriteLine("[Launcher] Preview mode: UpdateWindow");
|
||||
{
|
||||
Logger.Info("Preview command: update.");
|
||||
var updateWindow = new UpdateWindow();
|
||||
updateWindow.SetDebugMode(true);
|
||||
updateWindow.Show();
|
||||
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
|
||||
return true;
|
||||
|
||||
}
|
||||
case "preview-oobe":
|
||||
Console.WriteLine("[Launcher] Preview mode: OobeWindow");
|
||||
{
|
||||
Logger.Info("Preview command: oobe.");
|
||||
var oobeWindow = new OobeWindow();
|
||||
oobeWindow.Show();
|
||||
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
|
||||
return true;
|
||||
|
||||
}
|
||||
case "preview-debug":
|
||||
Console.WriteLine("[Launcher] Preview mode: DevDebugWindow");
|
||||
{
|
||||
Logger.Info("Preview command: debug window.");
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
return true;
|
||||
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 Splash 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||
{
|
||||
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
||||
var messages = new[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
|
||||
var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" };
|
||||
var reporter = (ISplashStageReporter)window;
|
||||
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
|
||||
for (var i = 0; i < stages.Length; i++)
|
||||
{
|
||||
reporter.Report(stages[i], messages[i]);
|
||||
await Task.Delay(800);
|
||||
await Task.Delay(800).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 等待5秒后自动关闭
|
||||
await Task.Delay(5000);
|
||||
|
||||
await Task.Delay(5000).ConfigureAwait(false);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 Update 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
|
||||
{
|
||||
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
|
||||
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
|
||||
for (var i = 0; i < stages.Length; i++)
|
||||
{
|
||||
window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
|
||||
await Task.Delay(600);
|
||||
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
|
||||
await Task.Delay(600).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
window.ReportComplete(true, null);
|
||||
|
||||
// 等待3秒后自动关闭
|
||||
await Task.Delay(3000);
|
||||
await Task.Delay(3000).ConfigureAwait(false);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
|
||||
string GetStageName(string stage) => stage switch
|
||||
{
|
||||
"verify" => "验证",
|
||||
"extract" => "解压",
|
||||
"apply" => "应用",
|
||||
"plugins" => "升级插件",
|
||||
"cleanup" => "清理",
|
||||
_ => stage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 OOBE 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 等待用户点击开始按钮
|
||||
await window.WaitForEnterAsync();
|
||||
Console.WriteLine("[Launcher] OOBE preview completed by user");
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
Logger.Info("OOBE preview completed by user.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
|
||||
Logger.Error("OOBE preview failed.", ex);
|
||||
}
|
||||
|
||||
// 用户点击后关闭应用程序
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待窗口关闭
|
||||
/// </summary>
|
||||
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
|
||||
{
|
||||
var tcs = new TaskCompletionSource();
|
||||
window.Closed += (s, e) => tcs.TrySetResult();
|
||||
await tcs.Task;
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
window.Closed += (_, _) => tcs.TrySetResult();
|
||||
await tcs.Task.ConfigureAwait(false);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
|
||||
private static async Task RunCoordinatorWithSplashAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
ErrorWindow? errorWindow = null;
|
||||
LauncherFlowCoordinator? coordinator = null;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
// 在 try-catch 块中实例化所有服务,确保异常被捕获
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
|
||||
coordinator = new LauncherFlowCoordinator(
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
@@ -226,88 +193,85 @@ public partial class App : Application
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获异常并显示错误窗口
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"启动器发生错误: {ex.Message}",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
|
||||
Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
|
||||
|
||||
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 安全关闭 Splash 窗口
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception closeEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Error closing splash window: {closeEx.Message}");
|
||||
}
|
||||
|
||||
// 创建并显示错误窗口
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage($"启动器发生错误:\n{ex.Message}\n\n请检查应用安装是否完整,或尝试重新安装。");
|
||||
errorWindow.Show();
|
||||
Console.WriteLine("[Launcher] ErrorWindow shown successfully");
|
||||
}
|
||||
catch (Exception windowEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Failed to show ErrorWindow: {windowEx.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// 如果错误窗口成功显示,等待它关闭
|
||||
if (errorWindow != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 等待用户选择或窗口关闭
|
||||
var errorResult = await errorWindow.WaitForChoiceAsync();
|
||||
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
|
||||
}
|
||||
catch (Exception waitEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Error waiting for ErrorWindow: {waitEx.Message}");
|
||||
// 如果等待失败,至少给用户5秒时间看到错误信息
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 错误窗口未能显示,等待5秒让用户看到控制台输出
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
}
|
||||
catch (Exception uiEx)
|
||||
{
|
||||
// 最后的兜底:记录到控制台
|
||||
Console.Error.WriteLine($"[Launcher] Critical error in UI thread: {uiEx.Message}");
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
}
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success &&
|
||||
result.Code is not "host_not_found" &&
|
||||
(string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
|
||||
/// </summary>
|
||||
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ShowFailureWindowAsync(LauncherResult result)
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage(
|
||||
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
||||
errorWindow.Show();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to show launcher failure window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunApplyUpdateWithWindowAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
@@ -324,8 +288,7 @@ public partial class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 应用增量更新
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
|
||||
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
@@ -333,24 +296,20 @@ public partial class App : Application
|
||||
errorMessage = updateResult.Message;
|
||||
}
|
||||
|
||||
// 2. 应用待处理的插件升级
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60));
|
||||
var pluginsDir = context.GetOption("plugins-dir")
|
||||
?? Path.Combine(appRoot, "plugins");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
|
||||
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success && queueResult.Code != "noop")
|
||||
{
|
||||
// 插件升级失败不阻断整体流程,仅记录到控制台
|
||||
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
|
||||
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理旧版本,保留至少3个版本以支持回滚
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
|
||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
}
|
||||
}
|
||||
@@ -358,33 +317,26 @@ public partial class App : Application
|
||||
{
|
||||
success = false;
|
||||
errorMessage = ex.Message;
|
||||
Logger.Error("Apply-update flow failed.", ex);
|
||||
}
|
||||
|
||||
// 显示完成状态,短暂停留后关闭
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
|
||||
|
||||
if (success)
|
||||
{
|
||||
// 成功:停留 1.5 秒让用户看到"更新完成"
|
||||
await Task.Delay(1500);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 失败:停留 5 秒让用户看到错误信息
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "apply-update",
|
||||
Code = success ? "ok" : "failed",
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["command"] = context.Command,
|
||||
["launchSource"] = context.LaunchSource
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true)]
|
||||
[JsonSerializable(typeof(SignedFileMap))]
|
||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
||||
@@ -22,6 +25,7 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(PluginManifest))]
|
||||
[JsonSerializable(typeof(PendingUpgrade))]
|
||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||
[JsonSerializable(typeof(OobeStateFile))]
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
|
||||
@@ -4,6 +4,19 @@ namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal sealed class CommandContext
|
||||
{
|
||||
private const string LaunchSourceOptionName = "launch-source";
|
||||
|
||||
private static readonly string[] GuiCommands =
|
||||
[
|
||||
"launch",
|
||||
"apply-update",
|
||||
"preview-splash",
|
||||
"preview-error",
|
||||
"preview-update",
|
||||
"preview-oobe",
|
||||
"preview-debug"
|
||||
];
|
||||
|
||||
public string Command { get; }
|
||||
|
||||
public string SubCommand { get; }
|
||||
@@ -20,6 +33,8 @@ internal sealed class CommandContext
|
||||
Options.ContainsKey("plugins-dir") &&
|
||||
Options.ContainsKey("result");
|
||||
|
||||
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||
@@ -28,6 +43,20 @@ internal sealed class CommandContext
|
||||
Options.ContainsKey("debug") ||
|
||||
System.Diagnostics.Debugger.IsAttached;
|
||||
|
||||
public bool IsPreviewCommand =>
|
||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGuiCommand =>
|
||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsMaintenanceCommand =>
|
||||
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string? ExplicitAppRoot => GetOption("app-root");
|
||||
|
||||
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
|
||||
{
|
||||
Command = command;
|
||||
@@ -62,6 +91,44 @@ internal sealed class CommandContext
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private string InferLaunchSource()
|
||||
{
|
||||
if (IsPreviewCommand)
|
||||
{
|
||||
return "debug-preview";
|
||||
}
|
||||
|
||||
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "apply-update";
|
||||
}
|
||||
|
||||
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "plugin-install";
|
||||
}
|
||||
|
||||
return "normal";
|
||||
}
|
||||
|
||||
private static string? NormalizeLaunchSource(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"normal" => "normal",
|
||||
"postinstall" => "postinstall",
|
||||
"apply-update" => "apply-update",
|
||||
"plugin-install" => "plugin-install",
|
||||
"debug-preview" => "debug-preview",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal enum OobeStateStatus
|
||||
{
|
||||
FirstRun,
|
||||
Completed,
|
||||
Unavailable,
|
||||
Suppressed
|
||||
}
|
||||
|
||||
internal sealed class OobeStateFile
|
||||
{
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
|
||||
public string CompletedAtUtc { get; init; } = string.Empty;
|
||||
|
||||
public string UserName { get; init; } = string.Empty;
|
||||
|
||||
public string? UserSid { get; init; }
|
||||
|
||||
public string LaunchSource { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class OobeLaunchDecision
|
||||
{
|
||||
public OobeStateStatus Status { get; init; }
|
||||
|
||||
public bool ShouldShowOobe { get; init; }
|
||||
|
||||
public string StatePath { get; init; } = string.Empty;
|
||||
|
||||
public string LaunchSource { get; init; } = "normal";
|
||||
|
||||
public bool IsElevated { get; init; }
|
||||
|
||||
public string UserName { get; init; } = string.Empty;
|
||||
|
||||
public string? UserSid { get; init; }
|
||||
|
||||
public string ResultCode { get; init; } = "ok";
|
||||
|
||||
public string SuppressionReason { get; init; } = string.Empty;
|
||||
|
||||
public string ErrorMessage { get; init; } = string.Empty;
|
||||
|
||||
public bool UsedLegacyMarker { get; init; }
|
||||
|
||||
public bool MigratedLegacyMarker { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class OobeCompletionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string ResultCode { get; init; } = "ok";
|
||||
|
||||
public string ErrorMessage { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record LauncherExecutionSnapshot(
|
||||
bool IsElevated,
|
||||
string UserName,
|
||||
string? UserSid);
|
||||
@@ -10,32 +10,60 @@ internal static class Program
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
Logger.Initialize();
|
||||
Logger.Info(
|
||||
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
|
||||
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
|
||||
$"LaunchSource='{commandContext.LaunchSource}'; IsElevated={execution.IsElevated}; " +
|
||||
$"UserSid='{execution.UserSid ?? string.Empty}'; " +
|
||||
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
|
||||
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
|
||||
|
||||
// 处理遗留插件安装命令
|
||||
if (commandContext.IsLegacyPluginInstall)
|
||||
try
|
||||
{
|
||||
var installer = new PluginInstallerService();
|
||||
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||
}
|
||||
if (commandContext.IsLegacyPluginInstall)
|
||||
{
|
||||
var installer = new PluginInstallerService();
|
||||
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!commandContext.IsGuiCommand)
|
||||
{
|
||||
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
|
||||
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
|
||||
// 处理其他 CLI 命令 (update, plugin, rollback 等)
|
||||
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||
}
|
||||
Logger.Error("Launcher failed before GUI flow completed.", ex);
|
||||
|
||||
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
var result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launcher",
|
||||
Code = "launcher_bootstrap_failed",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.ToString(),
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["command"] = commandContext.Command,
|
||||
["subCommand"] = commandContext.SubCommand,
|
||||
["launchSource"] = commandContext.LaunchSource,
|
||||
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
|
||||
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
|
||||
["isElevated"] = execution.IsElevated.ToString(),
|
||||
["userSid"] = execution.UserSid ?? string.Empty,
|
||||
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? string.Empty
|
||||
}
|
||||
};
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(commandContext.GetOption("result"), result).ConfigureAwait(false);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
|
||||
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
@@ -33,7 +33,6 @@ internal sealed class DeploymentLocator
|
||||
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
|
||||
|
||||
// ClassIsland 风格的查询:先筛选,后排序
|
||||
var validInstallations = candidates
|
||||
.Where(path =>
|
||||
{
|
||||
@@ -79,38 +78,199 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
public string? ResolveHostExecutablePath()
|
||||
public HostResolutionResult ResolveHostExecutable(CommandContext context)
|
||||
{
|
||||
// 使用新的灵活定位器
|
||||
var options = new HostDiscoveryOptions
|
||||
{
|
||||
ExecutableName = "LanMountainDesktop",
|
||||
PreferDevModeConfig = true,
|
||||
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
|
||||
AdditionalSearchPaths = new List<string>
|
||||
{
|
||||
// 可以通过配置文件或环境变量添加更多路径
|
||||
"${AppRoot}",
|
||||
"${AppRoot}/..",
|
||||
"${BaseDirectory}/../..",
|
||||
}
|
||||
};
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var locator = new FlexibleHostLocator(_appRoot, options);
|
||||
var result = locator.ResolveHostExecutablePath();
|
||||
|
||||
if (result != null)
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var searchedPaths = new List<string>();
|
||||
var explicitAppRoot = context.ExplicitAppRoot;
|
||||
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
|
||||
|
||||
string? resolvedPath;
|
||||
string? source;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
|
||||
{
|
||||
return result;
|
||||
var explicitRoot = Path.GetFullPath(explicitAppRoot);
|
||||
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
|
||||
}
|
||||
|
||||
// 回退到旧逻辑(作为备选)
|
||||
if (resolvedPath is null && context.IsDebugMode)
|
||||
{
|
||||
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
|
||||
}
|
||||
|
||||
if (resolvedPath is null)
|
||||
{
|
||||
resolvedPath = ResolveHostExecutablePathLegacy();
|
||||
if (!string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
searchedPaths.Add(Path.GetFullPath(resolvedPath));
|
||||
source = "legacy_fallback";
|
||||
}
|
||||
}
|
||||
|
||||
return new HostResolutionResult
|
||||
{
|
||||
Success = !string.IsNullOrWhiteSpace(resolvedPath),
|
||||
ResolvedHostPath = resolvedPath,
|
||||
ResolutionSource = source,
|
||||
AppRoot = _appRoot,
|
||||
ExplicitAppRoot = explicitAppRoot,
|
||||
DevModeConfigIgnored = devModeConfigIgnored,
|
||||
SearchedPaths = searchedPaths
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
return ResolveHostExecutablePathLegacy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传统的主程序路径解析(作为备选)
|
||||
/// </summary>
|
||||
private string? TryResolveExplicitAppRoot(
|
||||
string explicitRoot,
|
||||
string executable,
|
||||
List<string> searchedPaths,
|
||||
out string? source)
|
||||
{
|
||||
var directPath = Path.Combine(explicitRoot, executable);
|
||||
searchedPaths.Add(directPath);
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
source = "explicit_app_root_direct";
|
||||
return directPath;
|
||||
}
|
||||
|
||||
var deployment = FindBestDeploymentHost(explicitRoot, executable, searchedPaths);
|
||||
if (deployment is not null)
|
||||
{
|
||||
source = "explicit_app_root_deployment";
|
||||
return deployment;
|
||||
}
|
||||
|
||||
source = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? TryResolvePublishedOrPortableHost(
|
||||
string executable,
|
||||
List<string> searchedPaths,
|
||||
out string? source)
|
||||
{
|
||||
var deployment = FindBestDeploymentHost(_appRoot, executable, searchedPaths);
|
||||
if (deployment is not null)
|
||||
{
|
||||
source = "published_deployment";
|
||||
return deployment;
|
||||
}
|
||||
|
||||
var portableCandidates = new[]
|
||||
{
|
||||
Path.Combine(_appRoot, executable),
|
||||
Path.Combine(AppContext.BaseDirectory, executable)
|
||||
};
|
||||
|
||||
foreach (var candidate in portableCandidates
|
||||
.Select(Path.GetFullPath)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
searchedPaths.Add(candidate);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
source = "portable_host";
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
source = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? TryResolveDebugHost(
|
||||
string executable,
|
||||
List<string> searchedPaths,
|
||||
out string? source)
|
||||
{
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
||||
{
|
||||
var fullSavedPath = Path.GetFullPath(savedCustomPath);
|
||||
searchedPaths.Add(fullSavedPath);
|
||||
if (File.Exists(fullSavedPath))
|
||||
{
|
||||
source = "debug_saved_custom_path";
|
||||
return fullSavedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var devPath in GetDevelopmentPaths(executable))
|
||||
{
|
||||
var fullPath = Path.GetFullPath(devPath);
|
||||
searchedPaths.Add(fullPath);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
source = "debug_build_output";
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
source = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindBestDeploymentHost(
|
||||
string root,
|
||||
string executable,
|
||||
List<string> searchedPaths)
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
searchedPaths.Add(Path.Combine(root, "app-*", executable));
|
||||
return null;
|
||||
}
|
||||
|
||||
var appDirs = Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
HostPath = Path.Combine(path, executable),
|
||||
HasCurrent = File.Exists(Path.Combine(path, ".current")),
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.HasCurrent)
|
||||
.ThenByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
foreach (var candidate in appDirs)
|
||||
{
|
||||
searchedPaths.Add(candidate.HostPath);
|
||||
if (File.Exists(candidate.HostPath))
|
||||
{
|
||||
return candidate.HostPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (appDirs.Count == 0)
|
||||
{
|
||||
searchedPaths.Add(Path.Combine(root, "app-*", executable));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ResolveHostExecutablePathLegacy()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
@@ -126,14 +286,12 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
|
||||
var inRoot = Path.Combine(_appRoot, executable);
|
||||
if (File.Exists(inRoot))
|
||||
{
|
||||
return inRoot;
|
||||
}
|
||||
|
||||
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
|
||||
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
var inParent = Path.Combine(parent, executable);
|
||||
if (File.Exists(inParent))
|
||||
@@ -144,14 +302,12 @@ internal sealed class DeploymentLocator
|
||||
// 4. å¼€å<E282AC>‘模å¼<C3A5>:如果å<C593>¯ç”¨äº†å¼€å<E282AC>‘模å¼<C3A5>,优先使用ä¿<C3A4>å˜çš„自定义路径
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
// 4.1 首先检查保存的自定义路径
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
|
||||
{
|
||||
return savedCustomPath;
|
||||
}
|
||||
|
||||
// 4.2 扫描开发路径
|
||||
var devPath = ScanDevelopmentPaths(executable);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
@@ -179,7 +335,7 @@ internal sealed class DeploymentLocator
|
||||
{
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 从 Launcher 项目运行
|
||||
// ä»?Launcher 项目è¿<EFBFBD>行
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
@@ -203,17 +359,14 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取开发环境可能的主程序路径
|
||||
/// </summary>
|
||||
/// 获å<EFBFBD>–å¼€å<EFBFBD>‘环境å<EFBFBD>¯èƒ½çš„主程åº<EFBFBD>è·¯å¾? /// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
// 获取 Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 可能的开发目录结构
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 从 Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// ä»?Launcher 项目è¿<EFBFBD>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
@@ -221,7 +374,7 @@ internal sealed class DeploymentLocator
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 从 dev-test 目录运行
|
||||
// ä»?dev-test 目录è¿<EFBFBD>行
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
@@ -256,9 +409,8 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理旧版本部署,保留最近的N个版本
|
||||
/// </summary>
|
||||
/// <param name="minVersionsToKeep">最少保留版本数,默认3个</param>
|
||||
/// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最近的N个版æœ? /// </summary>
|
||||
/// <param name="minVersionsToKeep">最少ä¿<C3A4>留版本数,默è®?ä¸?/param>
|
||||
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
||||
{
|
||||
try
|
||||
@@ -272,7 +424,6 @@ internal sealed class DeploymentLocator
|
||||
|
||||
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
// 过滤掉无效部署目录(排除partial),按版本排序
|
||||
var validDeployments = candidates
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||
.Select(path => new
|
||||
@@ -349,7 +500,6 @@ internal sealed class DeploymentLocator
|
||||
{
|
||||
if (versionsToKeep.Contains(deployment.Path))
|
||||
{
|
||||
// 保留此版本,如果之前标记了destroy则取消标记
|
||||
if (deployment.IsDestroyed)
|
||||
{
|
||||
try
|
||||
@@ -365,7 +515,6 @@ internal sealed class DeploymentLocator
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果还没标记destroy的,先标记
|
||||
if (!deployment.IsDestroyed)
|
||||
{
|
||||
try
|
||||
@@ -387,7 +536,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败(可能文件被占用),下次启动再试
|
||||
// å¿½ç•¥åˆ é™¤å¤±è´¥(å<>¯èƒ½æ–‡ä»¶è¢«å<C2AB> ç”?,下次å<C2A1>¯åЍå†<C3A5>试
|
||||
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
|
||||
}
|
||||
}
|
||||
@@ -400,7 +549,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅清理已标记为.destroy的部署(兼容旧方法)
|
||||
/// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroy的部署(兼容旧方法)
|
||||
/// </summary>
|
||||
[Obsolete("Use CleanupOldDeployments instead")]
|
||||
public void CleanupDestroyedDeployments()
|
||||
@@ -432,8 +581,7 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从部署目录读取版本信息
|
||||
/// </summary>
|
||||
/// 从部署目录读å<EFBFBD>–版本信æ<EFBFBD>? /// </summary>
|
||||
public AppVersionInfo GetVersionInfo()
|
||||
{
|
||||
var deploymentDir = FindCurrentDeploymentDirectory();
|
||||
@@ -453,16 +601,16 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略读取失败,回退到默认值
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:从目录名解析版本,使用默认开发代号
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate" // 默认开发代号
|
||||
Codename = "Administrate"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
18
LanMountainDesktop.Launcher/Services/HostResolutionResult.cs
Normal file
18
LanMountainDesktop.Launcher/Services/HostResolutionResult.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class HostResolutionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string? ResolvedHostPath { get; init; }
|
||||
|
||||
public string? ResolutionSource { get; init; }
|
||||
|
||||
public string AppRoot { get; init; } = string.Empty;
|
||||
|
||||
public string? ExplicitAppRoot { get; init; }
|
||||
|
||||
public bool DevModeConfigIgnored { get; init; }
|
||||
|
||||
public List<string> SearchedPaths { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Principal;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal static class LauncherExecutionContext
|
||||
{
|
||||
public static LauncherExecutionSnapshot Capture()
|
||||
{
|
||||
var userName = Environment.UserName ?? string.Empty;
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new LauncherExecutionSnapshot(false, userName, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var principal = new WindowsPrincipal(identity);
|
||||
return new LauncherExecutionSnapshot(
|
||||
principal.IsInRole(WindowsBuiltInRole.Administrator),
|
||||
userName,
|
||||
identity.User?.Value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new LauncherExecutionSnapshot(false, userName, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -262,6 +262,9 @@ internal sealed class LegacyVersionDetector
|
||||
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
|
||||
var fileName = parts[0].Trim('"');
|
||||
var arguments = parts.Length > 1 ? parts[1] : "";
|
||||
Logger.Info(
|
||||
$"Opening legacy uninstall interface with elevation reason 'legacy_uninstall'. " +
|
||||
$"InstallPath='{info.InstallPath}'; Version='{info.Version}'.");
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
|
||||
@@ -1,104 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class OobeStateService
|
||||
{
|
||||
private readonly string _markerPath;
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
public OobeStateService(string appRoot)
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _statePath;
|
||||
private readonly string _legacyMarkerPath;
|
||||
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||
|
||||
public OobeStateService(
|
||||
string appRoot,
|
||||
string? stateRootOverride = null,
|
||||
LauncherExecutionSnapshot? executionSnapshot = null)
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户目录,普通用户一定有权限)
|
||||
string? stateDir = null;
|
||||
Exception? lastException = null;
|
||||
_ = Path.GetFullPath(appRoot);
|
||||
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||
|
||||
// 策略1: LocalApplicationData(首选,用户目录,普通用户一定有写权限)
|
||||
try
|
||||
{
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
stateDir = Path.Combine(appDataDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
|
||||
// 策略2: 如果LocalApplicationData不行,使用用户的临时目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
stateDir = tempDir;
|
||||
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 最后的兜底:使用当前用户的应用程序数据目录(和Launcher同目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
stateDir = Path.Combine(launcherDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
|
||||
// 如果所有策略都失败,抛出异常让上层处理
|
||||
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
|
||||
}
|
||||
}
|
||||
|
||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
||||
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
|
||||
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? GetDefaultStateRoot()
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
public OobeLaunchDecision Evaluate(CommandContext context)
|
||||
{
|
||||
var decision = EvaluateCore(context);
|
||||
Logger.Info(
|
||||
$"OOBE decision evaluated. LaunchSource='{decision.LaunchSource}'; Status='{decision.Status}'; " +
|
||||
$"ShouldShow={decision.ShouldShowOobe}; IsElevated={decision.IsElevated}; " +
|
||||
$"StatePath='{decision.StatePath}'; SuppressionReason='{decision.SuppressionReason}'; " +
|
||||
$"ResultCode='{decision.ResultCode}'; UserSid='{decision.UserSid ?? string.Empty}'.");
|
||||
return decision;
|
||||
}
|
||||
|
||||
public OobeCompletionResult MarkCompleted(CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var payload = new OobeStateFile
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
LaunchSource = context.LaunchSource
|
||||
};
|
||||
|
||||
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
|
||||
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _statePath, overwrite: true);
|
||||
TryDeleteLegacyMarker();
|
||||
|
||||
Logger.Info(
|
||||
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
|
||||
|
||||
return new OobeCompletionResult
|
||||
{
|
||||
Success = true,
|
||||
ResultCode = "ok"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
|
||||
// 如果无法检查,默认视为首次运行,确保OOBE能显示
|
||||
return true;
|
||||
Logger.Warn(
|
||||
$"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||
$"Error='{ex.Message}'.");
|
||||
return new OobeCompletionResult
|
||||
{
|
||||
Success = false,
|
||||
ResultCode = "oobe_state_unavailable",
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
private OobeLaunchDecision EvaluateCore(CommandContext context)
|
||||
{
|
||||
if (string.Equals(context.LaunchSource, "debug-preview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildSuppressedDecision(context, "debug_preview", "oobe_suppressed_debug_preview");
|
||||
}
|
||||
|
||||
if (context.IsMaintenanceCommand)
|
||||
{
|
||||
return BuildSuppressedDecision(context, "maintenance", "oobe_suppressed_maintenance");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
var migratedLegacyMarker = false;
|
||||
if (File.Exists(_statePath))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
using var stream = File.OpenRead(_statePath);
|
||||
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
|
||||
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
|
||||
{
|
||||
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: false);
|
||||
}
|
||||
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
Console.WriteLine("[OobeStateService] Marked first run as completed");
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
{
|
||||
migratedLegacyMarker = TryMigrateLegacyMarker(context);
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
|
||||
}
|
||||
|
||||
if (_executionSnapshot.IsElevated)
|
||||
{
|
||||
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
|
||||
}
|
||||
|
||||
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
|
||||
// 如果无法写入也没关系,下次启动还会显示OOBE
|
||||
return BuildUnavailableDecision(context, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryMigrateLegacyMarker(CommandContext context)
|
||||
{
|
||||
var result = MarkCompleted(context);
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
private void TryDeleteLegacyMarker()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
{
|
||||
File.Delete(_legacyMarkerPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private OobeLaunchDecision BuildDecision(
|
||||
CommandContext context,
|
||||
OobeStateStatus status,
|
||||
bool shouldShowOobe,
|
||||
bool usedLegacyMarker = false,
|
||||
bool migratedLegacyMarker = false)
|
||||
{
|
||||
return new OobeLaunchDecision
|
||||
{
|
||||
Status = status,
|
||||
ShouldShowOobe = shouldShowOobe,
|
||||
StatePath = _statePath,
|
||||
LaunchSource = context.LaunchSource,
|
||||
IsElevated = _executionSnapshot.IsElevated,
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
UsedLegacyMarker = usedLegacyMarker,
|
||||
MigratedLegacyMarker = migratedLegacyMarker,
|
||||
ResultCode = "ok"
|
||||
};
|
||||
}
|
||||
|
||||
private OobeLaunchDecision BuildSuppressedDecision(CommandContext context, string reason, string resultCode)
|
||||
{
|
||||
return new OobeLaunchDecision
|
||||
{
|
||||
Status = OobeStateStatus.Suppressed,
|
||||
ShouldShowOobe = false,
|
||||
StatePath = _statePath,
|
||||
LaunchSource = context.LaunchSource,
|
||||
IsElevated = _executionSnapshot.IsElevated,
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
SuppressionReason = reason,
|
||||
ResultCode = resultCode
|
||||
};
|
||||
}
|
||||
|
||||
private OobeLaunchDecision BuildUnavailableDecision(CommandContext context, string errorMessage)
|
||||
{
|
||||
return new OobeLaunchDecision
|
||||
{
|
||||
Status = OobeStateStatus.Unavailable,
|
||||
ShouldShowOobe = false,
|
||||
StatePath = _statePath,
|
||||
LaunchSource = context.LaunchSource,
|
||||
IsElevated = _executionSnapshot.IsElevated,
|
||||
UserName = _executionSnapshot.UserName,
|
||||
UserSid = _executionSnapshot.UserSid,
|
||||
ResultCode = "oobe_state_unavailable",
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDefaultStateRoot()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||
}
|
||||
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ internal sealed class PluginInstallerService
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
||||
{
|
||||
return elevationRequiredResult;
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
@@ -51,6 +56,46 @@ internal sealed class PluginInstallerService
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
|
||||
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
|
||||
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Logger.Warn(
|
||||
$"Plugin installation requires explicit elevation. Reason='plugin_requires_elevation'; " +
|
||||
$"PluginsDirectory='{pluginsDirectory}'; AllowedRoot='{allowedRoot}'.");
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin.install",
|
||||
Code = "plugin_elevation_required",
|
||||
Message = "Plugin installation outside the current user's LanMountainDesktop data directory requires explicit elevation.",
|
||||
ErrorMessage = "Plugin installation target is outside the current user's LanMountainDesktop data directory.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pluginsDirectory"] = pluginsDirectory,
|
||||
["allowedRoot"] = allowedRoot,
|
||||
["elevationReason"] = "outside_user_scope"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
|
||||
50
LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs
Normal file
50
LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly CommandContext _context;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
|
||||
{
|
||||
_oobeStateService = oobeStateService;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
OobeWindow? window = null;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
window = new OobeWindow();
|
||||
window.Show();
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
var completion = _oobeStateService.MarkCompleted(_context);
|
||||
if (!completion.Success)
|
||||
{
|
||||
Logger.Warn(
|
||||
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " +
|
||||
$"Error='{completion.ErrorMessage}'.");
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (window.IsVisible)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,12 @@ public partial class LoadingDetailsWindow : Window
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 初始化列表
|
||||
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
|
||||
if (itemsList != null)
|
||||
{
|
||||
itemsList.ItemsSource = _items;
|
||||
}
|
||||
|
||||
// 创建更新定时器
|
||||
_updateTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100)
|
||||
@@ -59,8 +57,7 @@ public partial class LoadingDetailsWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新加载状态
|
||||
/// </summary>
|
||||
/// 鏇存柊鍔犺浇鐘舵€? /// </summary>
|
||||
public void UpdateLoadingState(LoadingStateMessage state)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
@@ -73,7 +70,6 @@ public partial class LoadingDetailsWindow : Window
|
||||
// 鏇存柊鏁翠綋杩涘害
|
||||
UpdateOverallProgress(state);
|
||||
|
||||
// 更新当前活动项
|
||||
UpdateCurrentItem(state);
|
||||
|
||||
// 鏇存柊鍒楄〃
|
||||
@@ -124,8 +120,7 @@ public partial class LoadingDetailsWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前活动项
|
||||
/// </summary>
|
||||
/// 鏇存柊褰撳墠娲诲姩椤? /// </summary>
|
||||
private void UpdateCurrentItem(LoadingStateMessage state)
|
||||
{
|
||||
var currentItem = state.ActiveItems.FirstOrDefault();
|
||||
@@ -162,7 +157,6 @@ public partial class LoadingDetailsWindow : Window
|
||||
/// </summary>
|
||||
private void UpdateItemsList(LoadingStateMessage state)
|
||||
{
|
||||
// 同步列表项
|
||||
foreach (var item in state.ActiveItems)
|
||||
{
|
||||
var existing = _items.FirstOrDefault(i => i.Id == item.Id);
|
||||
@@ -187,7 +181,7 @@ public partial class LoadingDetailsWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
// 按状态排序:进行中 -> 等待中 -> 已完成 -> 失败
|
||||
// 鎸夌姸鎬佹帓搴忥細杩涜<EFBFBD>涓?-> 绛夊緟涓?-> 宸插畬鎴?-> 澶辫触
|
||||
var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
|
||||
_items.Clear();
|
||||
foreach (var item in sortedItems)
|
||||
@@ -240,17 +234,20 @@ public partial class LoadingDetailsWindow : Window
|
||||
/// </summary>
|
||||
private static string GetStageDescription(StartupStage stage) => stage switch
|
||||
{
|
||||
StartupStage.Initializing => "正在初始化系统...",
|
||||
StartupStage.LoadingSettings => "正在加载设置...",
|
||||
StartupStage.LoadingPlugins => "正在加载插件...",
|
||||
StartupStage.InitializingUI => "正在初始化界面...",
|
||||
StartupStage.Ready => "加载完成",
|
||||
_ => "正在加载..."
|
||||
StartupStage.Initializing => "正在初始化系统...",
|
||||
StartupStage.LoadingSettings => "正在加载设置...",
|
||||
StartupStage.LoadingPlugins => "正在加载插件...",
|
||||
StartupStage.InitializingUI => "正在初始化界面...",
|
||||
StartupStage.ShellInitialized => "桌面外壳已初始化",
|
||||
StartupStage.DesktopVisible => "桌面已经可见",
|
||||
StartupStage.ActivationRedirected => "已激活现有实例",
|
||||
StartupStage.ActivationFailed => "现有实例激活失败",
|
||||
StartupStage.Ready => "加载完成",
|
||||
_ => "正在加载..."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取项描述
|
||||
/// </summary>
|
||||
/// 鑾峰彇椤规弿杩? /// </summary>
|
||||
private static string GetItemDescription(LoadingItem item)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Description))
|
||||
@@ -268,8 +265,7 @@ public partial class LoadingDetailsWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取项图标
|
||||
/// </summary>
|
||||
/// 鑾峰彇椤瑰浘鏍? /// </summary>
|
||||
private static string GetItemIcon(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => "\uE768",
|
||||
@@ -298,8 +294,7 @@ public partial class LoadingDetailsWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载项视图模型
|
||||
/// </summary>
|
||||
/// 鍔犺浇椤硅<EFBFBD>鍥炬ā鍨?/// </summary>
|
||||
public class LoadingItemViewModel : INotifyPropertyChanged
|
||||
{
|
||||
public string Id { get; }
|
||||
@@ -394,3 +389,4 @@ public class LoadingItemViewModel : INotifyPropertyChanged
|
||||
_ => new SolidColorBrush(Color.Parse("#616161"))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,38 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 启动阶段枚举
|
||||
/// </summary>
|
||||
public enum StartupStage
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化中
|
||||
/// </summary>
|
||||
Initializing,
|
||||
|
||||
/// <summary>
|
||||
/// 加载设置中
|
||||
/// </summary>
|
||||
LoadingSettings,
|
||||
|
||||
/// <summary>
|
||||
/// 加载插件中
|
||||
/// </summary>
|
||||
LoadingPlugins,
|
||||
|
||||
/// <summary>
|
||||
/// 初始化界面中
|
||||
/// </summary>
|
||||
InitializingUI,
|
||||
|
||||
/// <summary>
|
||||
/// 就绪
|
||||
/// </summary>
|
||||
ShellInitialized,
|
||||
DesktopVisible,
|
||||
ActivationRedirected,
|
||||
ActivationFailed,
|
||||
Ready
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动进度消息
|
||||
/// </summary>
|
||||
public record StartupProgressMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前阶段
|
||||
/// </summary>
|
||||
public StartupStage Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进度百分比 (0-100)
|
||||
/// </summary>
|
||||
|
||||
public int ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 常量
|
||||
/// </summary>
|
||||
public static class LauncherIpcConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// 命名管道名称
|
||||
/// </summary>
|
||||
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||
|
||||
/// <summary>
|
||||
/// Launcher 进程 ID 环境变量
|
||||
/// </summary>
|
||||
|
||||
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
|
||||
|
||||
/// <summary>
|
||||
/// 包根目录环境变量
|
||||
/// </summary>
|
||||
|
||||
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
|
||||
|
||||
/// <summary>
|
||||
/// 版本环境变量
|
||||
/// </summary>
|
||||
|
||||
public const string VersionEnvVar = "LMD_VERSION";
|
||||
|
||||
/// <summary>
|
||||
/// 开发代号环境变量
|
||||
/// </summary>
|
||||
|
||||
public const string CodenameEnvVar = "LMD_CODENAME";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
25
LanMountainDesktop.Tests/CommandContextTests.cs
Normal file
25
LanMountainDesktop.Tests/CommandContextTests.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using LanMountainDesktop.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class CommandContextTests
|
||||
{
|
||||
public static TheoryData<string[], string> LaunchSourceCases => new()
|
||||
{
|
||||
{ [], "normal" },
|
||||
{ ["preview-oobe"], "debug-preview" },
|
||||
{ ["apply-update"], "apply-update" },
|
||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LaunchSourceCases))]
|
||||
public void FromArgs_InfersExpectedLaunchSource(string[] args, string expectedLaunchSource)
|
||||
{
|
||||
var context = CommandContext.FromArgs(args);
|
||||
|
||||
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
124
LanMountainDesktop.Tests/OobeStateServiceTests.cs
Normal file
124
LanMountainDesktop.Tests/OobeStateServiceTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class OobeStateServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.Tests", nameof(OobeStateServiceTests), Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsFirstRun_ForNormalLaunch_WhenStateIsMissing()
|
||||
{
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
|
||||
Assert.True(decision.ShouldShowOobe);
|
||||
Assert.Equal("normal", decision.LaunchSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsCompleted_WhenStateFileExists()
|
||||
{
|
||||
var statePath = GetStatePath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(statePath)!);
|
||||
var state = new OobeStateFile
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
UserName = "tester",
|
||||
UserSid = "S-1-5-test",
|
||||
LaunchSource = "normal"
|
||||
};
|
||||
File.WriteAllText(statePath, JsonSerializer.Serialize(state));
|
||||
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Completed, decision.Status);
|
||||
Assert.False(decision.ShouldShowOobe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MigratesLegacyMarker_AndTreatsItAsCompleted()
|
||||
{
|
||||
var legacyMarkerPath = GetLegacyMarkerPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(legacyMarkerPath)!);
|
||||
File.WriteAllText(legacyMarkerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Completed, decision.Status);
|
||||
Assert.True(decision.UsedLegacyMarker);
|
||||
Assert.True(decision.MigratedLegacyMarker);
|
||||
Assert.True(File.Exists(GetStatePath()));
|
||||
Assert.False(File.Exists(legacyMarkerPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SuppressesOobe_ForElevatedFirstRun()
|
||||
{
|
||||
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
|
||||
Assert.False(decision.ShouldShowOobe);
|
||||
Assert.Equal("oobe_suppressed_elevated", decision.ResultCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsUnavailable_ForInvalidStateFile()
|
||||
{
|
||||
var statePath = GetStatePath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(statePath)!);
|
||||
File.WriteAllText(statePath, "{ this is not valid json }");
|
||||
|
||||
var service = CreateService();
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
var decision = service.Evaluate(context);
|
||||
|
||||
Assert.Equal(OobeStateStatus.Unavailable, decision.Status);
|
||||
Assert.False(decision.ShouldShowOobe);
|
||||
Assert.Equal("oobe_state_unavailable", decision.ResultCode);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private OobeStateService CreateService(LauncherExecutionSnapshot? executionSnapshot = null)
|
||||
{
|
||||
return new OobeStateService(
|
||||
appRoot: _tempRoot,
|
||||
stateRootOverride: _tempRoot,
|
||||
executionSnapshot: executionSnapshot ?? new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
|
||||
}
|
||||
|
||||
private string GetStatePath() => Path.Combine(_tempRoot, ".launcher", "state", "oobe-state.json");
|
||||
|
||||
private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed");
|
||||
}
|
||||
42
LanMountainDesktop.Tests/PluginInstallerServiceTests.cs
Normal file
42
LanMountainDesktop.Tests/PluginInstallerServiceTests.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginInstallerServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.Tests", nameof(PluginInstallerServiceTests), Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReturnsElevationRequired_ForOutsideUserScope_OnWindows()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var packagePath = Path.Combine(_tempRoot, "sample.lmdp");
|
||||
File.WriteAllText(packagePath, "placeholder");
|
||||
|
||||
var service = new PluginInstallerService();
|
||||
var result = service.InstallPackage(packagePath, Path.Combine(_tempRoot, "Plugins"));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("plugin_elevation_required", result.Code);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
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" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -19,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;
|
||||
@@ -54,6 +58,7 @@ 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 ISettingsPageRegistry? _settingsPageRegistry;
|
||||
private ISettingsWindowService? _settingsWindowService;
|
||||
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||
@@ -74,11 +79,15 @@ public partial class App : Application
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
private LauncherIpcClient? _launcherIpcClient;
|
||||
private PublicIpcHostService? _publicIpcHostService;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
private int _forcedExitScheduled;
|
||||
private bool _mainWindowOpened;
|
||||
private bool _trayInitialized;
|
||||
private readonly object _launcherProgressLock = new();
|
||||
private readonly List<StartupProgressMessage> _pendingLauncherProgressMessages = [];
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
@@ -86,10 +95,9 @@ public partial class App : Application
|
||||
internal static INotificationService? CurrentNotificationService =>
|
||||
(Current as App)?._notificationService;
|
||||
|
||||
// 隐私政策查看事件
|
||||
// 闅愮鏀跨瓥鏌ョ湅浜嬩欢
|
||||
public static event Action? CurrentPrivacyPolicyViewRequested;
|
||||
|
||||
// 触发隐私政策查看事件的方法
|
||||
public static void RaisePrivacyPolicyViewRequested()
|
||||
{
|
||||
CurrentPrivacyPolicyViewRequested?.Invoke();
|
||||
@@ -109,8 +117,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()
|
||||
@@ -156,6 +164,8 @@ public partial class App : Application
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePublicIpc();
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
|
||||
@@ -164,37 +174,33 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
|
||||
// 使用 fire-and-forget 模式,不阻塞主流程
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
}
|
||||
|
||||
private async Task InitializeLauncherIpcAsync()
|
||||
{
|
||||
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
||||
if (_loadingStateManager is not null)
|
||||
return;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
_launcherIpcClient = new LauncherIpcClient();
|
||||
var connected = await _launcherIpcClient.ConnectAsync();
|
||||
|
||||
if (connected)
|
||||
bool hadBufferedMessages;
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
|
||||
|
||||
// 初始化加载状态管理器
|
||||
_loadingStateManager = new LoadingStateManager();
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
|
||||
_loadingStateReporter.Start();
|
||||
|
||||
// 注册系统初始化加载项
|
||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
|
||||
_loadingStateManager.StartItem("system.init", "已连接启动器");
|
||||
|
||||
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
|
||||
hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0;
|
||||
}
|
||||
|
||||
_loadingStateManager = new LoadingStateManager();
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _publicIpcHostService);
|
||||
_loadingStateReporter.Start();
|
||||
|
||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services.");
|
||||
_loadingStateManager.StartItem("system.init", "Public IPC host ready.");
|
||||
await FlushPendingLauncherProgressAsync();
|
||||
|
||||
if (!hadBufferedMessages)
|
||||
{
|
||||
ReportStartupProgress(StartupStage.Initializing, 10, "Initializing application...");
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "Loading settings...");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -203,67 +209,86 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向 Launcher 报告启动进度(fire-and-forget,不阻塞主流程)
|
||||
/// </summary>
|
||||
private void ReportStartupProgress(StartupStage stage, int percent, string message)
|
||||
{
|
||||
if (_launcherIpcClient is null)
|
||||
return;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
QueueOrSendLauncherProgress(new StartupProgressMessage
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
}
|
||||
});
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}, logSuccess: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
|
||||
/// 用于 Ready 等关键状态报告
|
||||
/// </summary>
|
||||
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
|
||||
{
|
||||
if (_launcherIpcClient is null)
|
||||
return;
|
||||
QueueOrSendLauncherProgress(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}, logSuccess: true);
|
||||
}
|
||||
|
||||
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
|
||||
{
|
||||
var publicIpcHostService = _publicIpcHostService;
|
||||
if (publicIpcHostService is null)
|
||||
{
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
_pendingLauncherProgressMessages.Add(message);
|
||||
}
|
||||
|
||||
AppLogger.Info("LauncherIpc", $"Buffered launcher stage '{message.Stage}' because IPC is not connected yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
_ = SendLauncherProgressAsync(publicIpcHostService, message, logSuccess);
|
||||
}
|
||||
|
||||
private async Task FlushPendingLauncherProgressAsync()
|
||||
{
|
||||
var publicIpcHostService = _publicIpcHostService;
|
||||
if (publicIpcHostService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartupProgressMessage[] pendingMessages;
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
pendingMessages = _pendingLauncherProgressMessages.ToArray();
|
||||
_pendingLauncherProgressMessages.Clear();
|
||||
}
|
||||
|
||||
foreach (var pendingMessage in pendingMessages)
|
||||
{
|
||||
await SendLauncherProgressAsync(publicIpcHostService, pendingMessage, logSuccess: false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendLauncherProgressAsync(PublicIpcHostService publicIpcHostService, StartupProgressMessage message, bool logSuccess)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
await publicIpcHostService.PublishStartupProgressAsync(message);
|
||||
if (logSuccess)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
});
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
}
|
||||
});
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
_pendingLauncherProgressMessages.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDesignTimeTheme()
|
||||
{
|
||||
RequestedThemeVariant = ThemeVariant.Light;
|
||||
@@ -289,7 +314,7 @@ public partial class App : Application
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
|
||||
ReportStartupProgress(StartupStage.InitializingUI, 60, "姝e湪鍒濆鍖栫晫闈?..");
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
},
|
||||
OnDesktopLifetimeExit,
|
||||
@@ -337,25 +362,21 @@ public partial class App : Application
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
// 仅在 Windows 上支持融合桌面功能
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换进入编辑模式,隐藏常态零散的小部件
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||
|
||||
// 确保透明覆盖层窗口存在并显示
|
||||
// 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
|
||||
EnsureTransparentOverlayWindow();
|
||||
|
||||
// 打开融合桌面组件库窗口
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 确保覆盖层窗口已显示(组件要渲染在上面,必须先 Show)
|
||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Show();
|
||||
@@ -368,16 +389,15 @@ public partial class App : Application
|
||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
||||
}
|
||||
|
||||
// 当组件库关闭时,退出编辑态
|
||||
window.Closed += (s, ev) =>
|
||||
window.Closed += (s, ev) =>
|
||||
{
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
// 触发画布保存,并隐藏画布
|
||||
// 瑙﹀彂鐢诲竷淇濆瓨锛屽苟闅愯棌鐢诲竷
|
||||
_transparentOverlayWindow.SaveLayoutAndHide();
|
||||
}
|
||||
|
||||
// 让管理器根据已存储的最新快照重建生成所有实体小组件
|
||||
// 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||
};
|
||||
|
||||
@@ -434,11 +454,11 @@ public partial class App : Application
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
{
|
||||
ReportStartupProgress(StartupStage.LoadingPlugins, 30, "正在加载插件...");
|
||||
ReportStartupProgress(StartupStage.LoadingPlugins, 30, "姝e湪鍔犺浇鎻掍欢...");
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
|
||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade, _publicIpcHostService);
|
||||
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
|
||||
_pluginRuntimeService.LoadInstalledPlugins();
|
||||
}
|
||||
@@ -489,9 +509,12 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
RefreshTrayIconContent();
|
||||
_trayInitialized = true;
|
||||
AppLogger.Info("TrayIcon", $"Tray initialized successfully. Pid={Environment.ProcessId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trayInitialized = false;
|
||||
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
|
||||
}
|
||||
}
|
||||
@@ -537,14 +560,12 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅在 Windows 上支持融合桌面功能
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_trayComponentLibraryMenuItem.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查融合桌面功能是否启用
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
|
||||
|
||||
@@ -717,7 +738,7 @@ public partial class App : Application
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.PrepareEnterAnimation();
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar();
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
@@ -855,13 +876,12 @@ public partial class App : Application
|
||||
|
||||
if (languageChanged)
|
||||
{
|
||||
// 清除本地化缓存,强制重新加载语言文件
|
||||
// 娓呴櫎鏈湴鍖栫紦瀛橈紝寮哄埗閲嶆柊鍔犺浇璇█鏂囦欢
|
||||
_localizationService.ClearCache();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
RefreshTrayIconContent();
|
||||
}
|
||||
|
||||
// 检查融合桌面设置是否变更
|
||||
var fusedDesktopChanged =
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFusedDesktop), StringComparer.OrdinalIgnoreCase);
|
||||
@@ -1018,6 +1038,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)
|
||||
{
|
||||
@@ -1073,67 +1106,52 @@ public partial class App : Application
|
||||
var mainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
ShowInTaskbar = true
|
||||
ShowInTaskbar = ShouldShowMainWindowInTaskbar()
|
||||
};
|
||||
|
||||
_mainWindowOpened = false;
|
||||
AttachMainWindow(mainWindow);
|
||||
desktop.MainWindow = mainWindow;
|
||||
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
|
||||
LogBrowserStartupDiagnostics();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
|
||||
|
||||
// 延迟报告 Ready 直到窗口实际打开并可见
|
||||
// 使用 Opened 事件确保所有资源已加载完毕
|
||||
ReportStartupProgress(StartupStage.ShellInitialized, 85, "Desktop shell initialized.");
|
||||
AppLogger.Info(
|
||||
"App",
|
||||
$"Shell initialized. Reason='{reason}'; TrayInitialized={_trayInitialized}; MainWindowVisible={mainWindow.IsVisible}.");
|
||||
|
||||
mainWindow.Opened += OnMainWindowOpened;
|
||||
|
||||
// 手动显示窗口,因为在 ShutdownMode.OnExplicitShutdown 模式下框架不会自动调用 Show
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
|
||||
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
|
||||
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
||||
if (!_mainWindowOpened)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.Ready,
|
||||
ProgressPercent = 100,
|
||||
Message = "就绪"
|
||||
});
|
||||
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
|
||||
}
|
||||
catch { }
|
||||
AppLogger.Warn("App", "Main window Opened event did not fire within 10 seconds. DesktopVisible was not reported.");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主窗口打开完成事件 - 此时所有组件、资源及功能模块均已完全加载
|
||||
/// </summary>
|
||||
private void OnMainWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is MainWindow mainWindow)
|
||||
{
|
||||
mainWindow.Opened -= OnMainWindowOpened;
|
||||
|
||||
AppLogger.Info("App", "Main window opened and ready. Reporting Ready to Launcher...");
|
||||
|
||||
// 完成系统初始化加载项
|
||||
_loadingStateManager?.CompleteItem("system.init", "系统初始化完成");
|
||||
|
||||
// 报告 Ready 状态,启动器可以安全关闭 Splash 窗口
|
||||
ReportStartupProgressSync(StartupStage.Ready, 100, "就绪");
|
||||
|
||||
// 停止加载状态上报
|
||||
_mainWindowOpened = true;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1278,6 +1296,11 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldShowMainWindowInTaskbar()
|
||||
{
|
||||
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
|
||||
}
|
||||
|
||||
private void SetDesktopShellState(DesktopShellState state, string source)
|
||||
{
|
||||
if (_desktopShellState == state)
|
||||
@@ -1326,4 +1349,56 @@ 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 version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||
_publicIpcHostService = new PublicIpcHostService();
|
||||
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
|
||||
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
|
||||
new PublicAppInfoService(version, "Administrate", _startupAt));
|
||||
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
|
||||
new PublicShellControlService());
|
||||
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
|
||||
new PublicPluginCatalogService(_publicIpcHostService));
|
||||
_publicIpcHostService.Start();
|
||||
AppLogger.Info("PublicIpc", $"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'.");
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -8,6 +8,7 @@ using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
@@ -33,6 +34,7 @@ public sealed class Program
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||
ReportLauncherStageBeforeExit(StartupStage.ActivationFailed, "Restart relaunch could not acquire the single-instance lock.");
|
||||
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
|
||||
return;
|
||||
}
|
||||
@@ -43,6 +45,7 @@ public sealed class Program
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
|
||||
ReportLauncherStageBeforeExit(StartupStage.ActivationRedirected, "Secondary launch forwarded to the primary instance.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
|
||||
}
|
||||
else
|
||||
@@ -50,6 +53,9 @@ public sealed class Program
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
|
||||
ReportLauncherStageBeforeExit(
|
||||
StartupStage.ActivationFailed,
|
||||
$"Secondary launch failed to activate the primary instance. Reason='{failureReason ?? "unknown"}'.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
|
||||
}
|
||||
|
||||
@@ -247,6 +253,35 @@ public sealed class Program
|
||||
};
|
||||
}
|
||||
|
||||
private static void ReportLauncherStageBeforeExit(StartupStage stage, string message)
|
||||
{
|
||||
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var launcherIpcClient = new LauncherIpcClient();
|
||||
var connected = launcherIpcClient.ConnectAsync().GetAwaiter().GetResult();
|
||||
if (!connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = 100,
|
||||
Message = message
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report early launcher stage '{stage}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeTelemetryIdentity()
|
||||
{
|
||||
try
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user