Compare commits

...

4 Commits

Author SHA1 Message Date
lincube
00747f33b2 Merge branch 'main' into codex/ipc 2026-04-22 10:25:08 +08:00
lincube
2c48b7b846 Add plugin isolation IPC scaffolding and host phase one docs 2026-04-22 10:15:07 +08:00
lincube
9224c9a33a Harden OOBE, launch-source and elevation flow
Introduce a per-user OOBE state model and hardened launch/elevation handling. Adds OobeStateFile/OobeLaunchDecision models, OobeStateService (persisting %LOCALAPPDATA%/.launcher/state/oobe-state.json), and LauncherExecutionContext to capture elevation and user SID. CommandContext now normalizes/infers launch-source values (normal, postinstall, apply-update, plugin-install, debug-preview) and exposes maintenance checks. LauncherFlowCoordinator propagates richer launcher context details for diagnostics and suppresses OOBE for elevated/maintenance contexts. PluginInstallerService avoids requesting elevation for user-scoped installs and returns a clear error when installation target is outside the current user's LocalAppData. LauncherClient maps and surfaces result codes, UpdateWorkflow and installer invocation now pass explicit --launch-source values, and WelcomeOobeStep persists OOBE completion via the new service. Adds unit tests (CommandContext, OobeStateService, PluginInstallerService), docs/specs/checklists for the contract, and makes internals visible to tests.
2026-04-22 09:25:22 +08:00
lincube
703ed7b48a Refactor launcher startup, logging & host resolution
Improve launcher startup flow, logging, and host resolution. Key changes: add detailed startup logging and standardized preview messages; unify CLI vs GUI handling and error/result reporting (write result file when requested); refactor DeploymentLocator to a more robust host resolution (new HostResolutionResult, explicit/portable/published/debug resolution paths, legacy fallback); overhaul LauncherFlowCoordinator to better handle IPC stages, activation retries, window lifecycle, plugin/update flows and error reporting; add CommandContext helpers (IsGui/IsPreview/ExplicitAppRoot) and JSON context options; tighten async usage and ConfigureAwait calls; add better UI error handling and consistent exit codes. Several UX/debug conveniences and robustness fixes included.
2026-04-22 07:31:54 +08:00
76 changed files with 3280 additions and 964 deletions

View File

@@ -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.

View 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.

View 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.

View File

@@ -6,3 +6,6 @@
- [x] Legacy plugin install arguments still execute. - [x] Legacy plugin install arguments still execute.
- [x] OOBE and splash are implemented as separate windows. - [x] OOBE and splash are implemented as separate windows.
- [x] Update and rollback logic use version directory markers. - [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`.

View File

@@ -52,3 +52,9 @@ Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
- `IOobeStep` for future multi-step OOBE - `IOobeStep` for future multi-step OOBE
- `ISplashStageReporter` for future startup progress visualization - `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.

View 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 回路

View 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 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。

View 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 壳层适配器
- [ ] 为故障、心跳、降级与恢复补齐端到端测试

View File

@@ -13,10 +13,15 @@ public partial class App : Application
{ {
public override void Initialize() public override void Initialize()
{ {
// 初始化日志记录器
Logger.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); AvaloniaXamlLoader.Load(this);
} }
@@ -24,41 +29,31 @@ public partial class App : Application
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var context = LauncherRuntimeContext.Current; 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)) if (HandlePreviewCommand(context, desktop))
{ {
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
return; return;
} }
// apply-update 模式:显示 UpdateWindow执行增量更新 + 插件升级
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{ {
// 先显示窗口,再启动后台任务
var updateWindow = new UpdateWindow(); var updateWindow = new UpdateWindow();
updateWindow.Show(); updateWindow.Show();
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow); _ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
} }
else else
{ {
// 先显示 Splash 窗口,确保应用程序不会立即退出
var splashWindow = new SplashWindow(); var splashWindow = new SplashWindow();
splashWindow.Show(); splashWindow.Show();
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow); _ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
} }
} }
@@ -66,156 +61,128 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
/// <summary>
/// 处理界面预览命令
/// </summary>
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop) private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{ {
var command = context.Command.ToLowerInvariant(); switch (context.Command.ToLowerInvariant())
switch (command)
{ {
case "preview-splash": case "preview-splash":
Console.WriteLine("[Launcher] Preview mode: SplashWindow"); {
Logger.Info("Preview command: splash.");
var splashWindow = new SplashWindow(); var splashWindow = new SplashWindow();
splashWindow.SetDebugMode(true); splashWindow.SetDebugMode(true);
splashWindow.Show(); splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow); _ = SimulateSplashPreviewAsync(desktop, splashWindow);
return true; return true;
}
case "preview-error": case "preview-error":
Console.WriteLine("[Launcher] Preview mode: ErrorWindow"); {
Logger.Info("Preview command: error.");
var errorWindow = new ErrorWindow(); var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。"); errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
errorWindow.Show(); errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow); _ = WaitForWindowCloseAsync(desktop, errorWindow);
return true; return true;
}
case "preview-update": case "preview-update":
Console.WriteLine("[Launcher] Preview mode: UpdateWindow"); {
Logger.Info("Preview command: update.");
var updateWindow = new UpdateWindow(); var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true); updateWindow.SetDebugMode(true);
updateWindow.Show(); updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow); _ = SimulateUpdatePreviewAsync(desktop, updateWindow);
return true; return true;
}
case "preview-oobe": case "preview-oobe":
Console.WriteLine("[Launcher] Preview mode: OobeWindow"); {
Logger.Info("Preview command: oobe.");
var oobeWindow = new OobeWindow(); var oobeWindow = new OobeWindow();
oobeWindow.Show(); oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow); _ = SimulateOobePreviewAsync(desktop, oobeWindow);
return true; return true;
}
case "preview-debug": case "preview-debug":
Console.WriteLine("[Launcher] Preview mode: DevDebugWindow"); {
Logger.Info("Preview command: debug window.");
var devDebugWindow = new DevDebugWindow(); var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show(); devDebugWindow.Show();
return true; return true;
}
default: default:
return false; return false;
} }
} }
/// <summary>
/// 模拟 Splash 窗口预览
/// </summary>
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window) private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{ {
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" }; 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; 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]); reporter.Report(stages[i], messages[i]);
await Task.Delay(800); await Task.Delay(800).ConfigureAwait(false);
} }
// 等待5秒后自动关闭 await Task.Delay(5000).ConfigureAwait(false);
await Task.Delay(5000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
} }
/// <summary>
/// 模拟 Update 窗口预览
/// </summary>
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window) private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{ {
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" }; 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); window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
await Task.Delay(600); await Task.Delay(600).ConfigureAwait(false);
} }
window.ReportComplete(true, null); window.ReportComplete(true, null);
await Task.Delay(3000).ConfigureAwait(false);
// 等待3秒后自动关闭
await Task.Delay(3000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); 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) private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{ {
try try
{ {
// 等待用户点击开始按钮 await window.WaitForEnterAsync().ConfigureAwait(false);
await window.WaitForEnterAsync(); Logger.Info("OOBE preview completed by user.");
Console.WriteLine("[Launcher] OOBE preview completed by user");
} }
catch (Exception ex) 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)); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
} }
/// <summary>
/// 等待窗口关闭
/// </summary>
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window) private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{ {
var tcs = new TaskCompletionSource(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
window.Closed += (s, e) => tcs.TrySetResult(); window.Closed += (_, _) => tcs.TrySetResult();
await tcs.Task; await tcs.Task.ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
} }
private static async Task RunCoordinatorWithSplashAsync( private static async Task RunCoordinatorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop, IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context, CommandContext context,
SplashWindow splashWindow) SplashWindow splashWindow)
{ {
LauncherResult result; LauncherResult result;
ErrorWindow? errorWindow = null;
LauncherFlowCoordinator? coordinator = null;
try try
{ {
// 在 try-catch 块中实例化所有服务,确保异常被捕获
var appRoot = Commands.ResolveAppRoot(context); var appRoot = Commands.ResolveAppRoot(context);
Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
var deploymentLocator = new DeploymentLocator(appRoot); var deploymentLocator = new DeploymentLocator(appRoot);
var coordinator = new LauncherFlowCoordinator(
// TODO: 从配置读取 GitHub 仓库信息
coordinator = new LauncherFlowCoordinator(
context, context,
deploymentLocator, deploymentLocator,
new OobeStateService(appRoot), new OobeStateService(appRoot),
@@ -226,88 +193,85 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
// 捕获异常并显示错误窗口 Logger.Error("Coordinator threw an unhandled exception.", ex);
result = new LauncherResult result = new LauncherResult
{ {
Success = false, Success = false,
Stage = "launch", Stage = "launch",
Code = "exception", Code = "exception",
Message = $"启动器发生错误: {ex.Message}", Message = $"Launcher failed: {ex.Message}",
ErrorMessage = ex.ToString() 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; Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
} }
/// <summary> private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出 {
/// </summary> 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( private static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop, IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context, CommandContext context,
@@ -324,8 +288,7 @@ public partial class App : Application
try try
{ {
// 1. 应用增量更新 await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success) if (!updateResult.Success)
{ {
@@ -333,24 +296,20 @@ public partial class App : Application
errorMessage = updateResult.Message; errorMessage = updateResult.Message;
} }
// 2. 应用待处理的插件升级
if (success) if (success)
{ {
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60)); await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
var pluginsDir = context.GetOption("plugins-dir") var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir); var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop") if (!queueResult.Success && queueResult.Code != "noop")
{ {
// 插件升级失败不阻断整体流程,仅记录到控制台 Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
} }
} }
// 3. 清理旧版本保留至少3个版本以支持回滚
if (success) 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); deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
} }
} }
@@ -358,33 +317,26 @@ public partial class App : Application
{ {
success = false; success = false;
errorMessage = ex.Message; errorMessage = ex.Message;
Logger.Error("Apply-update flow failed.", ex);
} }
// 显示完成状态,短暂停留后关闭
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage)); await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
if (success)
{
// 成功:停留 1.5 秒让用户看到"更新完成"
await Task.Delay(1500);
}
else
{
// 失败:停留 5 秒让用户看到错误信息
await Task.Delay(5000);
}
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{ {
Success = success, Success = success,
Stage = "apply-update", Stage = "apply-update",
Code = success ? "ok" : "failed", 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); }).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1; Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
} }
} }

View File

@@ -6,7 +6,10 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher; namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSourceGenerationOptions(
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(SignedFileMap))] [JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))] [JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(PlondsUpdateMetadata))] [JsonSerializable(typeof(PlondsUpdateMetadata))]
@@ -22,6 +25,7 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PluginManifest))] [JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))] [JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))] [JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(OobeStateFile))]
[JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))] [JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))] [JsonSerializable(typeof(List<GitHubRelease>))]

View File

@@ -4,6 +4,19 @@ namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext 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 Command { get; }
public string SubCommand { get; } public string SubCommand { get; }
@@ -20,6 +33,8 @@ internal sealed class CommandContext
Options.ContainsKey("plugins-dir") && Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result"); Options.ContainsKey("result");
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
/// <summary> /// <summary>
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动) /// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
/// 仅当明确指定 --debug 参数或调试器附加时才启用 /// 仅当明确指定 --debug 参数或调试器附加时才启用
@@ -28,6 +43,20 @@ internal sealed class CommandContext
Options.ContainsKey("debug") || Options.ContainsKey("debug") ||
System.Diagnostics.Debugger.IsAttached; 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) private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
{ {
Command = command; Command = command;
@@ -62,6 +91,44 @@ internal sealed class CommandContext
: fallback; : 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) private static Dictionary<string, string> ParseOptions(string[] args)
{ {
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

View 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);

View File

@@ -10,32 +10,60 @@ internal static class Program
private static async Task<int> Main(string[] args) private static async Task<int> Main(string[] args)
{ {
var commandContext = CommandContext.FromArgs(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>"}'.");
// 处理遗留插件安装命令 try
if (commandContext.IsLegacyPluginInstall)
{ {
var installer = new PluginInstallerService(); if (commandContext.IsLegacyPluginInstall)
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false); {
} 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; LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode; return Environment.ExitCode;
} }
catch (Exception ex)
// 处理其他 CLI 命令 (update, plugin, rollback 等)
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
{ {
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false); Logger.Error("Launcher failed before GUI flow completed.", ex);
}
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序 var result = new LauncherResult
LauncherRuntimeContext.Current = commandContext; {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); Success = false,
return Environment.ExitCode; 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() private static AppBuilder BuildAvaloniaApp()

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -33,7 +33,6 @@ internal sealed class DeploymentLocator
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly); var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories"); Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
// ClassIsland 风格的查询:先筛选,后排序
var validInstallations = candidates var validInstallations = candidates
.Where(path => .Where(path =>
{ {
@@ -79,38 +78,199 @@ internal sealed class DeploymentLocator
} }
} }
public string? ResolveHostExecutablePath() public HostResolutionResult ResolveHostExecutable(CommandContext context)
{ {
// 使用新的灵活定位器 ArgumentNullException.ThrowIfNull(context);
var options = new HostDiscoveryOptions
{
ExecutableName = "LanMountainDesktop",
PreferDevModeConfig = true,
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
AdditionalSearchPaths = new List<string>
{
// 可以通过配置文件或环境变量添加更多路径
"${AppRoot}",
"${AppRoot}/..",
"${BaseDirectory}/../..",
}
};
var locator = new FlexibleHostLocator(_appRoot, options); var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var result = locator.ResolveHostExecutablePath(); var searchedPaths = new List<string>();
var explicitAppRoot = context.ExplicitAppRoot;
if (result != null) 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(); return ResolveHostExecutablePathLegacy();
} }
/// <summary> private string? TryResolveExplicitAppRoot(
/// 传统的主程序路径解析(作为备选) string explicitRoot,
/// </summary> 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() private string? ResolveHostExecutablePathLegacy()
{ {
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
@@ -126,14 +286,12 @@ internal sealed class DeploymentLocator
} }
} }
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
var inRoot = Path.Combine(_appRoot, executable); var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot)) if (File.Exists(inRoot))
{ {
return inRoot; return inRoot;
} }
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
var parent = Path.GetFullPath(Path.Combine(_appRoot, "..")); var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable); var inParent = Path.Combine(parent, executable);
if (File.Exists(inParent)) if (File.Exists(inParent))
@@ -144,14 +302,12 @@ internal sealed class DeploymentLocator
// 4. å¼€å<E282AC>模å¼<C3A5>ï¼šå¦æžœå<C593>¯ç”¨äº†å¼€å<E282AC>模å¼<C3A5>,优先使用ä¿<C3A4>存的自定义路径 // 4. å¼€å<E282AC>模å¼<C3A5>ï¼šå¦æžœå<C593>¯ç”¨äº†å¼€å<E282AC>模å¼<C3A5>,优先使用ä¿<C3A4>存的自定义路径
if (Views.ErrorWindow.CheckDevModeEnabled()) if (Views.ErrorWindow.CheckDevModeEnabled())
{ {
// 4.1 首先检查保存的自定义路径
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath(); var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath)) if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
{ {
return savedCustomPath; return savedCustomPath;
} }
// 4.2 扫描开发路径
var devPath = ScanDevelopmentPaths(executable); var devPath = ScanDevelopmentPaths(executable);
if (!string.IsNullOrWhiteSpace(devPath)) if (!string.IsNullOrWhiteSpace(devPath))
{ {
@@ -179,7 +335,7 @@ internal sealed class DeploymentLocator
{ {
var possiblePaths = new[] var possiblePaths = new[]
{ {
// Launcher 项目运行 // ä»?Launcher 项ç®è¿<EFBFBD>行
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
@@ -203,17 +359,14 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 获取开发环境可能的主程序路径 /// 获å<EFBFBD>å¼€å<EFBFBD>环境å<EFBFBD>¯èƒ½çš„主ç¨åº<EFBFBD>è·¯å¾? /// </summary>
/// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable) private static IEnumerable<string> GetDevelopmentPaths(string executable)
{ {
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory; var launcherDir = AppContext.BaseDirectory;
// 可能的开发目录结构
var possiblePaths = new[] 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", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "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", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "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), Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
}; };
@@ -256,9 +409,8 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 清理旧版本部署保留最近的N个版本 /// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最è¿çš„N个版æœ? /// </summary>
/// </summary> /// <param name="minVersionsToKeep">最å°ä¿<C3A4>留版本数,默è®?ä¸?/param>
/// <param name="minVersionsToKeep">最少保留版本数默认3个</param>
public void CleanupOldDeployments(int minVersionsToKeep = 3) public void CleanupOldDeployments(int minVersionsToKeep = 3)
{ {
try try
@@ -272,7 +424,6 @@ internal sealed class DeploymentLocator
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly); var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
// 过滤掉无效部署目录排除partial按版本排序
var validDeployments = candidates var validDeployments = candidates
.Where(path => !File.Exists(Path.Combine(path, ".partial"))) .Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new .Select(path => new
@@ -349,7 +500,6 @@ internal sealed class DeploymentLocator
{ {
if (versionsToKeep.Contains(deployment.Path)) if (versionsToKeep.Contains(deployment.Path))
{ {
// 保留此版本如果之前标记了destroy则取消标记
if (deployment.IsDestroyed) if (deployment.IsDestroyed)
{ {
try try
@@ -365,7 +515,6 @@ internal sealed class DeploymentLocator
continue; continue;
} }
// 如果还没标记destroy的先标记
if (!deployment.IsDestroyed) if (!deployment.IsDestroyed)
{ {
try try
@@ -387,7 +536,7 @@ internal sealed class DeploymentLocator
} }
catch catch
{ {
// 忽略删除失败(可能文件被占用),下次启动再试 // 忽略删除失败(å<>¯èƒ½æ‡ä»¶è¢«å<C2AB> ç”?,䏿¬¡å<C2A1>¯åЍå†<C3A5>试
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}"); Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
} }
} }
@@ -400,7 +549,7 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 仅清理已标记为.destroy的部署兼容旧方法 /// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroyçš„éƒ¨ç½²ï¼ˆå…¼å®¹æ—§æ¹æ³•)
/// </summary> /// </summary>
[Obsolete("Use CleanupOldDeployments instead")] [Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments() public void CleanupDestroyedDeployments()
@@ -432,8 +581,7 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 从部署目录读取版本信息 /// 从部署ç®å½•读å<EFBFBD>版本信æ<EFBFBD>? /// </summary>
/// </summary>
public AppVersionInfo GetVersionInfo() public AppVersionInfo GetVersionInfo()
{ {
var deploymentDir = FindCurrentDeploymentDirectory(); var deploymentDir = FindCurrentDeploymentDirectory();
@@ -453,16 +601,16 @@ internal sealed class DeploymentLocator
} }
catch catch
{ {
// 忽略读取失败,回退到默认值
} }
} }
} }
// 回退:从目录名解析版本,使用默认开发代号
return new AppVersionInfo return new AppVersionInfo
{ {
Version = GetCurrentVersion(), Version = GetCurrentVersion(),
Codename = "Administrate" // 默认开发代号 Codename = "Administrate"
}; };
} }
} }

View 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; } = [];
}

View File

@@ -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);
}
}
}

View File

@@ -262,6 +262,9 @@ internal sealed class LegacyVersionDetector
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2); var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
var fileName = parts[0].Trim('"'); var fileName = parts[0].Trim('"');
var arguments = parts.Length > 1 ? parts[1] : ""; 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 Process.Start(new ProcessStartInfo
{ {

View File

@@ -1,104 +1,221 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Services;
internal sealed class OobeStateService 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用户目录普通用户一定有权限 _ = Path.GetFullPath(appRoot);
string? stateDir = null; _executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
Exception? lastException = null;
// 策略1: LocalApplicationData首选用户目录普通用户一定有写权限 var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
try ? GetDefaultStateRoot()
{ : Path.GetFullPath(stateRootOverride);
var appDataDir = Path.Combine( _stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), _statePath = Path.Combine(_stateDirectory, "oobe-state.json");
"LanMountainDesktop"); _legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
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}");
} }
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 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) catch (Exception ex)
{ {
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}"); Logger.Warn(
// 如果无法检查默认视为首次运行确保OOBE能显示 $"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
return true; $"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 try
{ {
var dir = Path.GetDirectoryName(_markerPath); var migratedLegacyMarker = false;
if (!string.IsNullOrWhiteSpace(dir)) 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")); if (File.Exists(_legacyMarkerPath))
Console.WriteLine("[OobeStateService] Marked first run as completed"); {
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) catch (Exception ex)
{ {
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}"); return BuildUnavailableDecision(context, ex.Message);
// 如果无法写入也没关系下次启动还会显示OOBE
} }
} }
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");
}
} }

View File

@@ -30,6 +30,11 @@ internal sealed class PluginInstallerService
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath); throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
} }
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
{
return elevationRequiredResult;
}
var manifest = ReadManifestFromPackage(fullSourcePath); var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory); Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); 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) public PluginManifest ReadManifestFromPackage(string packagePath)
{ {
using var archive = ZipFile.OpenRead(packagePath); using var archive = ZipFile.OpenRead(packagePath);

View 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();
}
});
}
}

View File

@@ -23,14 +23,12 @@ public partial class LoadingDetailsWindow : Window
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
// 初始化列表
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList"); var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
if (itemsList != null) if (itemsList != null)
{ {
itemsList.ItemsSource = _items; itemsList.ItemsSource = _items;
} }
// 创建更新定时器
_updateTimer = new DispatcherTimer _updateTimer = new DispatcherTimer
{ {
Interval = TimeSpan.FromMilliseconds(100) Interval = TimeSpan.FromMilliseconds(100)
@@ -59,8 +57,7 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 更新加载状态 /// 鏇存柊鍔犺浇鐘舵€? /// </summary>
/// </summary>
public void UpdateLoadingState(LoadingStateMessage state) public void UpdateLoadingState(LoadingStateMessage state)
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
@@ -73,7 +70,6 @@ public partial class LoadingDetailsWindow : Window
// 鏇存柊鏁翠綋杩涘害 // 鏇存柊鏁翠綋杩涘害
UpdateOverallProgress(state); UpdateOverallProgress(state);
// 更新当前活动项
UpdateCurrentItem(state); UpdateCurrentItem(state);
// 鏇存柊鍒楄〃 // 鏇存柊鍒楄〃
@@ -124,8 +120,7 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 更新当前活动项 /// 鏇存柊褰撳墠娲诲姩椤? /// </summary>
/// </summary>
private void UpdateCurrentItem(LoadingStateMessage state) private void UpdateCurrentItem(LoadingStateMessage state)
{ {
var currentItem = state.ActiveItems.FirstOrDefault(); var currentItem = state.ActiveItems.FirstOrDefault();
@@ -162,7 +157,6 @@ public partial class LoadingDetailsWindow : Window
/// </summary> /// </summary>
private void UpdateItemsList(LoadingStateMessage state) private void UpdateItemsList(LoadingStateMessage state)
{ {
// 同步列表项
foreach (var item in state.ActiveItems) foreach (var item in state.ActiveItems)
{ {
var existing = _items.FirstOrDefault(i => i.Id == item.Id); 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(); var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
_items.Clear(); _items.Clear();
foreach (var item in sortedItems) foreach (var item in sortedItems)
@@ -240,17 +234,20 @@ public partial class LoadingDetailsWindow : Window
/// </summary> /// </summary>
private static string GetStageDescription(StartupStage stage) => stage switch private static string GetStageDescription(StartupStage stage) => stage switch
{ {
StartupStage.Initializing => "正在初始化系统...", StartupStage.Initializing => "正在初始化系统...",
StartupStage.LoadingSettings => "正在加载设置...", StartupStage.LoadingSettings => "正在加载设置...",
StartupStage.LoadingPlugins => "正在加载插件...", StartupStage.LoadingPlugins => "正在加载插件...",
StartupStage.InitializingUI => "正在初始化界面...", StartupStage.InitializingUI => "正在初始化界面...",
StartupStage.Ready => "加载完成", StartupStage.ShellInitialized => "桌面外壳已初始化",
_ => "正在加载..." StartupStage.DesktopVisible => "桌面已经可见",
StartupStage.ActivationRedirected => "已激活现有实例",
StartupStage.ActivationFailed => "现有实例激活失败",
StartupStage.Ready => "加载完成",
_ => "正在加载..."
}; };
/// <summary> /// <summary>
/// 获取项描述 /// 鑾峰彇椤规弿杩? /// </summary>
/// </summary>
private static string GetItemDescription(LoadingItem item) private static string GetItemDescription(LoadingItem item)
{ {
if (!string.IsNullOrEmpty(item.Description)) if (!string.IsNullOrEmpty(item.Description))
@@ -268,8 +265,7 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 获取项图标 /// 鑾峰彇椤瑰浘鏍? /// </summary>
/// </summary>
private static string GetItemIcon(LoadingItemType type) => type switch private static string GetItemIcon(LoadingItemType type) => type switch
{ {
LoadingItemType.Plugin => "\uE768", LoadingItemType.Plugin => "\uE768",
@@ -298,8 +294,7 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 加载项视图模型 /// 鍔犺浇椤硅<EFBFBD>鍥炬ā鍨?/// </summary>
/// </summary>
public class LoadingItemViewModel : INotifyPropertyChanged public class LoadingItemViewModel : INotifyPropertyChanged
{ {
public string Id { get; } public string Id { get; }
@@ -394,3 +389,4 @@ public class LoadingItemViewModel : INotifyPropertyChanged
_ => new SolidColorBrush(Color.Parse("#616161")) _ => new SolidColorBrush(Color.Parse("#616161"))
}; };
} }

View File

@@ -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);

View File

@@ -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";
}

View File

@@ -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>

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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";
}
}

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIsolationProtocolVersion
{
public const string Current = "1.0";
}

View 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

View File

@@ -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);

View File

@@ -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);

View 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);

View File

@@ -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>

View 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);
}
}

View File

@@ -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;
}

View 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;
}

View 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);

View File

@@ -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;
}

View 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!;
}
}

View File

@@ -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;
}

View 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

View 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);
}

View 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; }
}

View File

@@ -25,6 +25,7 @@
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" /> <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.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -10,7 +10,8 @@ public sealed record PluginManifest(
string? Author = null, string? Author = null,
string? Version = null, string? Version = null,
string? ApiVersion = null, string? ApiVersion = null,
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null) IReadOnlyList<PluginSharedContractReference>? SharedContracts = null,
PluginRuntimeConfiguration? Runtime = null)
{ {
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {
@@ -56,9 +57,13 @@ public sealed record PluginManifest(
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly)); 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) private PluginManifest NormalizeAndValidate(string manifestPath)
{ {
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts); var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
var normalizedRuntime = (Runtime ?? new PluginRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
var normalized = this with var normalized = this with
{ {
Id = RequireValue(Id, nameof(Id), manifestPath), Id = RequireValue(Id, nameof(Id), manifestPath),
@@ -68,7 +73,8 @@ public sealed record PluginManifest(
Author = NormalizeOptionalValue(Author), Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version), Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion, ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
SharedContracts = normalizedSharedContracts SharedContracts = normalizedSharedContracts,
Runtime = normalizedRuntime
}; };
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion)) if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))

View 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)
};
}
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public enum PluginRuntimeMode
{
InProcess = 0,
IsolatedBackground = 1,
IsolatedWindow = 2
}

View 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.")
};
}
}

View 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;
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginWorkerEntranceAttribute : Attribute
{
}

View File

@@ -5,7 +5,9 @@ Official SDK package for LanMountainDesktop plugins.
## Includes ## Includes
- `IPlugin`/`PluginBase` entry abstractions - `IPlugin`/`PluginBase` entry abstractions
- `IPluginWorker`/`PluginWorkerBase` worker-side entry abstractions for isolated background mode
- `PluginManifest` and shared contract declarations - `PluginManifest` and shared contract declarations
- `runtime.mode` manifest support for `in-proc`, `isolated-background`, and `isolated-window`
- desktop component registration extensions - desktop component registration extensions
- plugin runtime context and host service abstractions - plugin runtime context and host service abstractions
- build-transitive packaging targets for `.laapp` output - build-transitive packaging targets for `.laapp` output

View File

@@ -22,3 +22,4 @@ Update `plugin.json` fields as needed before release:
- `description` - `description`
- `author` - `author`
- `version` - `version`
- `runtime.mode` (`in-proc` by default, `isolated-background` for phase-1 worker mode)

View File

@@ -6,5 +6,8 @@
"version": "1.0.0", "version": "1.0.0",
"apiVersion": "4.0.2", "apiVersion": "4.0.2",
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll", "entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
"sharedContracts": [] "sharedContracts": [],
"runtime": {
"mode": "in-proc"
}
} }

View File

@@ -1,89 +1,38 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher; namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// 启动阶段枚举
/// </summary>
public enum StartupStage public enum StartupStage
{ {
/// <summary>
/// 初始化中
/// </summary>
Initializing, Initializing,
/// <summary>
/// 加载设置中
/// </summary>
LoadingSettings, LoadingSettings,
/// <summary>
/// 加载插件中
/// </summary>
LoadingPlugins, LoadingPlugins,
/// <summary>
/// 初始化界面中
/// </summary>
InitializingUI, InitializingUI,
ShellInitialized,
/// <summary> DesktopVisible,
/// 就绪 ActivationRedirected,
/// </summary> ActivationFailed,
Ready Ready
} }
/// <summary>
/// 启动进度消息
/// </summary>
public record StartupProgressMessage public record StartupProgressMessage
{ {
/// <summary>
/// 当前阶段
/// </summary>
public StartupStage Stage { get; init; } public StartupStage Stage { get; init; }
/// <summary>
/// 进度百分比 (0-100)
/// </summary>
public int ProgressPercent { get; init; } public int ProgressPercent { get; init; }
/// <summary>
/// 状态消息
/// </summary>
public string? Message { get; init; } public string? Message { get; init; }
/// <summary>
/// 时间戳
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
} }
/// <summary>
/// Launcher IPC 常量
/// </summary>
public static class LauncherIpcConstants public static class LauncherIpcConstants
{ {
/// <summary>
/// 命名管道名称
/// </summary>
public const string PipeName = "LanMountainDesktop_Launcher"; public const string PipeName = "LanMountainDesktop_Launcher";
/// <summary>
/// Launcher 进程 ID 环境变量
/// </summary>
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID"; public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
/// <summary>
/// 包根目录环境变量
/// </summary>
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT"; public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
/// <summary>
/// 版本环境变量
/// </summary>
public const string VersionEnvVar = "LMD_VERSION"; public const string VersionEnvVar = "LMD_VERSION";
/// <summary>
/// 开发代号环境变量
/// </summary>
public const string CodenameEnvVar = "LMD_CODENAME"; public const string CodenameEnvVar = "LMD_CODENAME";
} }

View 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);
}
}

View File

@@ -18,5 +18,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" /> <ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View 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");
}

View 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
{
}
}
}

View 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);
}
}

View File

@@ -5,6 +5,8 @@
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" /> <Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" /> <Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.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.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" /> <Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" /> <Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -79,6 +80,10 @@ public partial class App : Application
private LoadingStateReporter? _loadingStateReporter; private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased; private bool _singleInstanceReleased;
private int _forcedExitScheduled; 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 SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle => internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
@@ -86,10 +91,9 @@ public partial class App : Application
internal static INotificationService? CurrentNotificationService => internal static INotificationService? CurrentNotificationService =>
(Current as App)?._notificationService; (Current as App)?._notificationService;
// 隐私政策查看事件 // 闅愮鏀跨瓥鏌ョ湅浜嬩欢
public static event Action? CurrentPrivacyPolicyViewRequested; public static event Action? CurrentPrivacyPolicyViewRequested;
// 触发隐私政策查看事件的方法
public static void RaisePrivacyPolicyViewRequested() public static void RaisePrivacyPolicyViewRequested()
{ {
CurrentPrivacyPolicyViewRequested?.Invoke(); CurrentPrivacyPolicyViewRequested?.Invoke();
@@ -156,6 +160,7 @@ public partial class App : Application
RegisterUiUnhandledExceptionGuard(); RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled(); LinuxDesktopEntryInstaller.EnsureInstalled();
_ = InitializeLauncherIpcAsync();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell); DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (!Design.IsDesignMode && OperatingSystem.IsWindows()) if (!Design.IsDesignMode && OperatingSystem.IsWindows())
@@ -164,37 +169,43 @@ public partial class App : Application
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
// 使用 fire-and-forget 模式,不阻塞主流程
_ = InitializeLauncherIpcAsync();
} }
private async Task InitializeLauncherIpcAsync() private async Task InitializeLauncherIpcAsync()
{ {
if (!LauncherIpcClient.IsLaunchedByLauncher()) if (!LauncherIpcClient.IsLaunchedByLauncher())
return; return;
try try
{ {
_launcherIpcClient = new LauncherIpcClient(); _launcherIpcClient = new LauncherIpcClient();
var connected = await _launcherIpcClient.ConnectAsync(); var connected = await _launcherIpcClient.ConnectAsync();
if (!connected)
if (connected)
{ {
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server."); return;
}
// 初始化加载状态管理器
_loadingStateManager = new LoadingStateManager(); AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
_loadingStateReporter.Start(); bool hadBufferedMessages;
lock (_launcherProgressLock)
// 注册系统初始化加载项 {
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件"); hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0;
_loadingStateManager.StartItem("system.init", "已连接启动器"); }
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化..."); await FlushPendingLauncherProgressAsync();
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
_loadingStateManager = new LoadingStateManager();
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
_loadingStateReporter.Start();
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services.");
_loadingStateManager.StartItem("system.init", "Launcher IPC connected.");
if (!hadBufferedMessages)
{
ReportStartupProgress(StartupStage.Initializing, 10, "Initializing application...");
ReportStartupProgress(StartupStage.LoadingSettings, 20, "Loading settings...");
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -203,67 +214,86 @@ public partial class App : Application
} }
} }
/// <summary>
/// 向 Launcher 报告启动进度fire-and-forget不阻塞主流程
/// </summary>
private void ReportStartupProgress(StartupStage stage, int percent, string message) private void ReportStartupProgress(StartupStage stage, int percent, string message)
{ {
if (_launcherIpcClient is null) QueueOrSendLauncherProgress(new StartupProgressMessage
return;
_ = Task.Run(async () =>
{ {
try Stage = stage,
{ ProgressPercent = percent,
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage Message = message,
{ Timestamp = DateTimeOffset.UtcNow
Stage = stage, }, logSuccess: false);
ProgressPercent = percent,
Message = message
});
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
}
});
} }
/// <summary>
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
/// 用于 Ready 等关键状态报告
/// </summary>
private void ReportStartupProgressSync(StartupStage stage, int percent, string message) private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
{ {
if (_launcherIpcClient is null) QueueOrSendLauncherProgress(new StartupProgressMessage
return; {
Stage = stage,
ProgressPercent = percent,
Message = message,
Timestamp = DateTimeOffset.UtcNow
}, logSuccess: true);
}
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
{
var ipcClient = _launcherIpcClient;
if (ipcClient is null || !ipcClient.IsConnected)
{
lock (_launcherProgressLock)
{
_pendingLauncherProgressMessages.Add(message);
}
AppLogger.Info("LauncherIpc", $"Buffered launcher stage '{message.Stage}' because IPC is not connected yet.");
return;
}
_ = SendLauncherProgressAsync(ipcClient, message, logSuccess);
}
private async Task FlushPendingLauncherProgressAsync()
{
var ipcClient = _launcherIpcClient;
if (ipcClient is null || !ipcClient.IsConnected)
{
return;
}
StartupProgressMessage[] pendingMessages;
lock (_launcherProgressLock)
{
pendingMessages = _pendingLauncherProgressMessages.ToArray();
_pendingLauncherProgressMessages.Clear();
}
foreach (var pendingMessage in pendingMessages)
{
await SendLauncherProgressAsync(ipcClient, pendingMessage, logSuccess: false);
}
}
private async Task SendLauncherProgressAsync(LauncherIpcClient ipcClient, StartupProgressMessage message, bool logSuccess)
{
try try
{ {
_ = Task.Run(async () => await ipcClient.ReportProgressAsync(message);
if (logSuccess)
{ {
try AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
{ }
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}");
}
});
} }
catch (Exception ex) 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() private void ApplyDesignTimeTheme()
{ {
RequestedThemeVariant = ThemeVariant.Light; RequestedThemeVariant = ThemeVariant.Light;
@@ -289,7 +319,7 @@ public partial class App : Application
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation(); DisableAvaloniaDataAnnotationValidation();
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown; desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面..."); ReportStartupProgress(StartupStage.InitializingUI, 60, "姝e湪鍒濆鍖栫晫闈?..");
CreateAndAssignMainWindow(desktop, "FrameworkInitialization"); CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
}, },
OnDesktopLifetimeExit, OnDesktopLifetimeExit,
@@ -337,25 +367,21 @@ public partial class App : Application
_ = sender; _ = sender;
_ = e; _ = e;
// 仅在 Windows 上支持融合桌面功能
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
{ {
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows."); AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
return; return;
} }
// 切换进入编辑模式,隐藏常态零散的小部件
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode(); FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
// 确保透明覆盖层窗口存在并显示 // 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
EnsureTransparentOverlayWindow(); EnsureTransparentOverlayWindow();
// 打开融合桌面组件库窗口
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
try try
{ {
// 确保覆盖层窗口已显示(组件要渲染在上面,必须先 Show
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible) if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
{ {
_transparentOverlayWindow.Show(); _transparentOverlayWindow.Show();
@@ -368,16 +394,15 @@ public partial class App : Application
window.SetOverlayWindow(_transparentOverlayWindow); window.SetOverlayWindow(_transparentOverlayWindow);
} }
// 当组件库关闭时,退出编辑态 window.Closed += (s, ev) =>
window.Closed += (s, ev) =>
{ {
if (_transparentOverlayWindow is not null) if (_transparentOverlayWindow is not null)
{ {
// 触发画布保存,并隐藏画布 // 瑙﹀彂鐢诲竷淇濆瓨锛屽苟闅愯棌鐢诲竷
_transparentOverlayWindow.SaveLayoutAndHide(); _transparentOverlayWindow.SaveLayoutAndHide();
} }
// 让管理器根据已存储的最新快照重建生成所有实体小组件 // 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}; };
@@ -434,7 +459,7 @@ public partial class App : Application
private void InitializePluginRuntime() private void InitializePluginRuntime()
{ {
ReportStartupProgress(StartupStage.LoadingPlugins, 30, "正在加载插件..."); ReportStartupProgress(StartupStage.LoadingPlugins, 30, "姝e湪鍔犺浇鎻掍欢...");
try try
{ {
_pluginRuntimeService?.Dispose(); _pluginRuntimeService?.Dispose();
@@ -489,9 +514,12 @@ public partial class App : Application
} }
RefreshTrayIconContent(); RefreshTrayIconContent();
_trayInitialized = true;
AppLogger.Info("TrayIcon", $"Tray initialized successfully. Pid={Environment.ProcessId}.");
} }
catch (Exception ex) catch (Exception ex)
{ {
_trayInitialized = false;
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex); AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
} }
} }
@@ -537,14 +565,12 @@ public partial class App : Application
return; return;
} }
// 仅在 Windows 上支持融合桌面功能
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
{ {
_trayComponentLibraryMenuItem.IsVisible = false; _trayComponentLibraryMenuItem.IsVisible = false;
return; return;
} }
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App); var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop; _trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
@@ -855,13 +881,12 @@ public partial class App : Application
if (languageChanged) if (languageChanged)
{ {
// 清除本地化缓存,强制重新加载语言文件 // 娓呴櫎鏈湴鍖栫紦瀛橈紝寮哄埗閲嶆柊鍔犺浇璇█鏂囦欢
_localizationService.ClearCache(); _localizationService.ClearCache();
ApplyCurrentCultureFromSettings(); ApplyCurrentCultureFromSettings();
RefreshTrayIconContent(); RefreshTrayIconContent();
} }
// 检查融合桌面设置是否变更
var fusedDesktopChanged = var fusedDesktopChanged =
refreshAll || refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFusedDesktop), StringComparer.OrdinalIgnoreCase); changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFusedDesktop), StringComparer.OrdinalIgnoreCase);
@@ -1076,64 +1101,49 @@ public partial class App : Application
ShowInTaskbar = true ShowInTaskbar = true
}; };
_mainWindowOpened = false;
AttachMainWindow(mainWindow); AttachMainWindow(mainWindow);
desktop.MainWindow = mainWindow; desktop.MainWindow = mainWindow;
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}"); AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
LogBrowserStartupDiagnostics(); LogBrowserStartupDiagnostics();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}"); SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
ReportStartupProgress(StartupStage.ShellInitialized, 85, "Desktop shell initialized.");
// 延迟报告 Ready 直到窗口实际打开并可见 AppLogger.Info(
// 使用 Opened 事件确保所有资源已加载完毕 "App",
$"Shell initialized. Reason='{reason}'; TrayInitialized={_trayInitialized}; MainWindowVisible={mainWindow.IsVisible}.");
mainWindow.Opened += OnMainWindowOpened; mainWindow.Opened += OnMainWindowOpened;
// 手动显示窗口,因为在 ShutdownMode.OnExplicitShutdown 模式下框架不会自动调用 Show
if (!mainWindow.IsVisible) if (!mainWindow.IsVisible)
{ {
mainWindow.Show(); mainWindow.Show();
} }
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await Task.Delay(TimeSpan.FromSeconds(10)); await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected) if (!_mainWindowOpened)
{ {
try AppLogger.Warn("App", "Main window Opened event did not fire within 10 seconds. DesktopVisible was not reported.");
{
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 { }
} }
}); });
return mainWindow; return mainWindow;
} }
/// <summary>
/// 主窗口打开完成事件 - 此时所有组件、资源及功能模块均已完全加载
/// </summary>
private void OnMainWindowOpened(object? sender, EventArgs e) private void OnMainWindowOpened(object? sender, EventArgs e)
{ {
if (sender is MainWindow mainWindow) if (sender is MainWindow mainWindow)
{ {
mainWindow.Opened -= OnMainWindowOpened; mainWindow.Opened -= OnMainWindowOpened;
_mainWindowOpened = true;
AppLogger.Info("App", "Main window opened and ready. Reporting Ready to Launcher...");
AppLogger.Info(
// 完成系统初始化加载项 "App",
_loadingStateManager?.CompleteItem("system.init", "系统初始化完成"); $"Main window opened. Reporting DesktopVisible. TrayInitialized={_trayInitialized}; ShellState='{_desktopShellState}'.");
// 报告 Ready 状态,启动器可以安全关闭 Splash 窗口 _loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
ReportStartupProgressSync(StartupStage.Ready, 100, "就绪"); ReportStartupProgressSync(StartupStage.DesktopVisible, 100, "Desktop visible.");
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
// 停止加载状态上报
_loadingStateReporter?.Stop(); _loadingStateReporter?.Stop();
} }
} }
@@ -1327,3 +1337,5 @@ public partial class App : Application
return _localizationService.GetString(languageCode, key, fallback); return _localizationService.GetString(languageCode, key, fallback);
} }
} }

View File

@@ -8,6 +8,7 @@ using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins; using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -33,6 +34,7 @@ public sealed class Program
AppLogger.Warn( AppLogger.Warn(
"Startup", "Startup",
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt."); $"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; Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
return; return;
} }
@@ -43,6 +45,7 @@ public sealed class Program
AppLogger.Info( AppLogger.Info(
"Startup", "Startup",
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}."); $"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; Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
} }
else else
@@ -50,6 +53,9 @@ public sealed class Program
AppLogger.Warn( AppLogger.Warn(
"Startup", "Startup",
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}."); $"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; 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() private static void InitializeTelemetryIdentity()
{ {
try try

View File

@@ -13,6 +13,11 @@ namespace LanMountainDesktop.Services.Launcher;
/// </summary> /// </summary>
public class LauncherIpcClient : IDisposable public class LauncherIpcClient : IDisposable
{ {
private static readonly JsonSerializerOptions StartupProgressJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private NamedPipeClientStream? _pipeClient; private NamedPipeClientStream? _pipeClient;
private bool _isConnected; private bool _isConnected;
private readonly object _writeLock = new(); private readonly object _writeLock = new();
@@ -65,7 +70,7 @@ public class LauncherIpcClient : IDisposable
try try
{ {
var json = JsonSerializer.Serialize(message); var json = JsonSerializer.Serialize(message, StartupProgressJsonOptions);
var payload = System.Text.Encoding.UTF8.GetBytes(json); var payload = System.Text.Encoding.UTF8.GetBytes(json);
// 长度前缀协议:[4字节长度][消息正文] // 长度前缀协议:[4字节长度][消息正文]

View File

@@ -5,6 +5,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -28,7 +29,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult( return new LauncherInstallResult(
false, false,
null, null,
"Elevated helper install is only supported on Windows."); "Elevated helper install is only supported on Windows.",
"failed");
} }
var launcherPath = ResolveLauncherPath(); var launcherPath = ResolveLauncherPath();
@@ -37,7 +39,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult( return new LauncherInstallResult(
false, false,
null, null,
$"Launcher executable was not found at '{launcherPath}'."); $"Launcher executable was not found at '{launcherPath}'.",
"failed");
} }
var resultPath = Path.Combine( var resultPath = Path.Combine(
@@ -53,14 +56,18 @@ internal sealed class LauncherClient
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath); using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
if (process is null) if (process is null)
{ {
return new LauncherInstallResult(false, null, "Failed to start launcher process."); return new LauncherInstallResult(false, null, "Failed to start launcher process.", "failed");
} }
await process.WaitForExitAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken);
var result = await ReadResultAsync(resultPath, cancellationToken); var result = await ReadResultAsync(resultPath, cancellationToken);
if (result is not null) if (result is not null)
{ {
return new LauncherInstallResult(result.Success, result.InstalledPackagePath, result.ErrorMessage); return new LauncherInstallResult(
result.Success,
result.InstalledPackagePath,
result.ErrorMessage ?? result.Message,
MapResultCode(result.Code));
} }
if (process.ExitCode == 0) if (process.ExitCode == 0)
@@ -68,7 +75,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult( return new LauncherInstallResult(
false, false,
null, null,
"Launcher exited without producing a result file."); "Launcher exited without producing a result file.",
"failed");
} }
return new LauncherInstallResult( return new LauncherInstallResult(
@@ -77,11 +85,12 @@ internal sealed class LauncherClient
string.Format( string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"Launcher exited with code {0}.", "Launcher exited with code {0}.",
process.ExitCode)); process.ExitCode),
"failed");
} }
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode) catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
{ {
return new LauncherInstallResult(false, null, "Administrator permission request was canceled."); return new LauncherInstallResult(false, null, "Administrator permission request was canceled.", "elevation_cancelled");
} }
finally finally
{ {
@@ -98,12 +107,11 @@ internal sealed class LauncherClient
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = launcherPath, FileName = launcherPath,
Verb = "runas",
UseShellExecute = true, UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory, WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
Arguments = string.Create( Arguments = string.Create(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}") $"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
}; };
return Process.Start(startInfo); return Process.Start(startInfo);
@@ -170,12 +178,32 @@ internal sealed class LauncherClient
} }
} }
private static string MapResultCode(string? launcherCode)
{
return launcherCode switch
{
"plugin_elevation_required" => "requires_elevation",
"elevation_cancelled" => "elevation_cancelled",
"ok" => "ok",
_ => "failed"
};
}
private sealed class HelperResultFile private sealed class HelperResultFile
{ {
[JsonPropertyName("success")]
public bool Success { get; init; } public bool Success { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
[JsonPropertyName("installedPackagePath")]
public string? InstalledPackagePath { get; init; } public string? InstalledPackagePath { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; } public string? ErrorMessage { get; init; }
} }
} }
@@ -183,4 +211,5 @@ internal sealed class LauncherClient
internal sealed record LauncherInstallResult( internal sealed record LauncherInstallResult(
bool Success, bool Success,
string? InstalledPackagePath, string? InstalledPackagePath,
string? ErrorMessage); string? ErrorMessage,
string Code);

View File

@@ -1454,7 +1454,7 @@ public sealed class UpdateWorkflowService
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = launcherPath, FileName = launcherPath,
Arguments = $"apply-update --app-root \"{launcherRoot}\"", Arguments = $"apply-update --app-root \"{launcherRoot}\" --launch-source apply-update",
UseShellExecute = false, UseShellExecute = false,
WorkingDirectory = launcherRoot WorkingDirectory = launcherRoot
}; };
@@ -1493,6 +1493,7 @@ public sealed class UpdateWorkflowService
try try
{ {
AppLogger.Info("UpdateWorkflow", "Launching pending full installer with elevation reason 'full_update_apply'.");
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = pending.InstallerPath, FileName = pending.InstallerPath,

View File

@@ -138,7 +138,7 @@ Name: "{autodesktop}\{cm:AppShortcutName}"; Filename: "{app}\{#MyAppExeName}"; T
Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
[Run] [Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#MyAppExeName}"; Parameters: "--launch-source postinstall"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[Code] [Code]
const const

View File

@@ -243,7 +243,8 @@ internal sealed class AirAppMarketInstallService : IDisposable
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed."; var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
AppLogger.Error( AppLogger.Error(
"PluginMarket", "PluginMarket",
$"Windows launcher install failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'."); $"Windows launcher install failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. " +
$"Code='{helperResult.Code}'; Message='{helperMessage}'.");
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage); return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
} }

View File

@@ -197,6 +197,35 @@ The runtime flow starts with the Launcher selecting the best version, then proce
## VeloPack Integration Note ## VeloPack Integration Note
- Incremental package build/publish has moved to VeloPack native assets ( - Incremental package build/publish has moved to VeloPack native assets (
eleases.win.json + *.nupkg).
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback. - Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
## Plugin Isolation Modes
The current plugin runtime is still in-process. `PluginRuntimeService` and `PluginLoader` load plugin code inside the Host process, while `PluginLoadContext` only provides assembly isolation, not process isolation.
The repository now reserves three runtime modes:
- `in-proc`: current default and compatibility mode
- `isolated-background`: phase-1 mode, where background logic moves into a dedicated worker process and Host UI becomes a thin IPC-driven shell
- `isolated-window`: phase-2 mode, where plugin UI renders out of process and Host embeds a platform window handle
Two new supporting packages define the isolation boundary:
- `LanMountainDesktop.PluginIsolation.Contracts/`: transport-neutral DTOs, route constants, error codes, capabilities, and JSON context
- `LanMountainDesktop.PluginIsolation.Ipc/`: ClassIsland-inspired IPC facade that centralizes startup constants, routed notify IDs, and client/server wrappers over the future `dotnetCampus.Ipc` transport binding
For the detailed design, migration path, UI strategy, and residual risks, see `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`.
## Launcher OOBE / Elevation Contract
- Launcher OOBE state is owned by a per-user JSON file under `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
- Same-user reinstall or upgrade should keep OOBE completed.
- `first_run_completed` is legacy migration-only data.
- The recognized launch sources are `normal`, `postinstall`, `apply-update`, `plugin-install`, and `debug-preview`.
- Auto-OOBE is only allowed for normal user-mode startup.
- `postinstall` may show OOBE only when the launcher is not elevated.
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-open OOBE.
- Elevation is allowed only for the installer, full installer update application, and user-confirmed legacy uninstall.
- Default plugin install should stay inside the user's LocalAppData scope and should not ask for UAC.

View File

@@ -547,3 +547,15 @@ var updateCheckService = new UpdateCheckService(
- [构建和部署指南](BUILD_AND_DEPLOY.md) - [构建和部署指南](BUILD_AND_DEPLOY.md)
- [架构文档](ARCHITECTURE.md) - [架构文档](ARCHITECTURE.md)
- [开发文档](DEVELOPMENT.md) - [开发文档](DEVELOPMENT.md)
## Current OOBE and Elevation Contract
- OOBE state is a per-user truth source stored at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
- Same-user reinstall or upgrade must not re-enter OOBE.
- `first_run_completed` is legacy compatibility data only and should not remain the long-term primary format.
- Launch source values are `normal`, `postinstall`, `apply-update`, `plugin-install`, and `debug-preview`.
- Auto-OOBE is allowed only for normal user-mode startup.
- `postinstall` may open OOBE only when the launcher is not elevated and the user state path 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, and user-confirmed legacy uninstall.
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.

View File

@@ -0,0 +1,263 @@
# 插件进程隔离架构
## 1. 背景与问题
当前插件系统只做了程序集隔离,没有做进程隔离。
- 宿主通过 `LanMountainDesktop/plugins/PluginRuntimeService.cs``LanMountainDesktop/plugins/PluginLoader.cs` 在 Host 进程内发现、加载并初始化插件。
- 插件依赖 `PluginLoadContext` 获得 `AssemblyLoadContext` 级别的依赖隔离,但代码、线程、托管堆和原生句柄仍与 Host 共处同一进程。
- 插件 `IHostedService` 也由 Host 直接构造并启动,所以插件后台逻辑和 Host 生命周期强耦合。
- 桌面组件、组件编辑器、设置页当前都直接返回 `Avalonia Control``SettingsPageBase`,并由 Host 直接插入视觉树。
这带来三个核心风险:
1. 插件崩溃会拖垮 Host典型场景包括 `StackOverflowException``AccessViolationException`、原生依赖崩溃。
2. 插件可直接访问 Host 进程中的服务实例与内存对象,缺少安全边界与权限审计点。
3. 现有“对象实例共享”模型难以迁移到跨进程,因为它默认调用成本近似于内存内方法调用。
## 2. 目标与非目标
### 2.1 一期目标
- 保持增量兼容,未声明新运行模式的插件继续走 `in-proc`
- 新增 `isolated-background` 运行模式,为每个隔离插件启动独立 Worker 进程。
- 把后台逻辑、定时任务、网络调用、原生高风险代码迁移到 Worker。
- UI 仍保留 Host 侧薄壳,通过 IPC 获取状态并发送命令。
- 新建独立 IPC 契约与封装层,为后续实际接线和插件升级提供稳定边界。
### 2.2 二期预留
- 预留 `isolated-window` 模式。
- 插件 UI 在进程外窗口中渲染Host 通过平台能力嵌入窗口句柄。
- Windows 侧可评估 `SetParent`Linux 侧可评估 `XEmbed` 或等价方案。
### 2.3 非目标
- 一期不强制所有插件升级。
- 一期不把现有 `IPluginExportRegistry``IPluginMessageBus` 直接升级成跨进程远程对象模型。
- 一期不实现完整的窗口嵌入渲染。
## 3. 运行模式设计
### 3.1 `in-proc`
- 默认模式。
- 继续使用当前 `PluginRuntimeService` + `PluginLoader` + `PluginLoadContext` 路径。
- 适合存量插件和仍依赖直接控件构造的插件。
### 3.2 `isolated-background`
- 一期目标模式。
- Host 为每个插件启动独立 Worker 进程。
- 启动时通过环境变量或命令行参数下发:
- `pluginId`
- `sessionId`
- `hostPipeName`
- `protocolVersion`
- `runtimeMode`
- Worker 内承载后台逻辑和 IPC 端点。
- Host 只保留 UI 壳层与状态同步逻辑。
### 3.3 `isolated-window`
- 二期预留模式。
- Worker 自己创建窗口并负责 UI 渲染。
- Host 负责窗口嵌入、生命周期协调、焦点与尺寸同步。
- 这是彻底切断插件 UI 崩溃影响 Host 的最终方案。
## 4. UI 方案取舍
### 4.1 方案一:进程外窗口
优点:
- 最强崩溃隔离。
- 插件 UI 不再进入 Host 视觉树。
- 安全边界更清晰。
缺点:
- 跨平台复杂度高。
- 窗口句柄嵌入、焦点管理、输入法、缩放、多屏和无障碍都需要额外设计。
- Avalonia 与平台窗口宿主的交互验证成本高。
### 4.2 方案二Host 薄 UI 壳层
优点:
- 与现有组件系统、编辑器系统、设置页系统的迁移成本最低。
- 可以先隔离最危险的后台与原生逻辑。
- 适合做增量兼容与插件生态迁移。
缺点:
- 如果 Host 仍执行插件提供的 UI 代码,仍有残余稳定性风险。
- 无法从根本上解决所有 UI 级崩溃。
### 4.3 一期结论
一期采用方案二,也就是 `isolated-background`
这意味着:
- 后台逻辑先隔离。
- UI 交互先代理。
- 文档必须明确残余风险。
- `isolated-window` 的架构接口要预留,但不进入一期实现。
## 5. IPC 协议设计
底层 IPC 继续基于 [dotnetCampus.Ipc](https://github.com/dotnet-campus/dotnetCampus.Ipc),但插件协议采用“显式路由 + DTO + 会话/心跳/故障管理”的方式,而不是把 Host 服务对象直接远程化。
### 5.1 路由分组
- `session/*`
- `session/handshake`
- `session/capabilities`
- `session/ready`
- `lifecycle/*`
- `lifecycle/initialize`
- `lifecycle/stop`
- `lifecycle/restart-request`
- `lifecycle/state-changed`
- `settings/*`
- `settings/get-snapshot`
- `settings/write`
- `settings/changed`
- `appearance/*`
- `appearance/get-snapshot`
- `appearance/changed`
- `ui/*`
- `ui/attach`
- `ui/detach`
- `ui/command`
- `ui/state-changed`
- `heartbeat/*`
- `heartbeat/ping`
- `heartbeat/pong`
- `log/*`
- `log/write`
- `fault/*`
- `fault/report`
### 5.2 契约原则
- 只传 DTO不传 Host 内存对象。
- 所有 handler 必须在 `StartServer()` 前注册完成。
- 使用 source-generated `System.Text.Json` 上下文统一序列化。
- 协议版本通过 `session/handshake` 协商。
- 能力通过显式 capability 列表声明和授予,不做隐式远程对象暴露。
### 5.3 明确不兼容的旧能力
- `IPluginExportRegistry` 的对象实例共享不延续到隔离模式。
- 现有 `IPluginMessageBus` 不作为隔离插件主通信通道。
- Worker 不直接创建 `Avalonia Control` 并返回给 Host。
## 6. 工程拆分
### 6.1 `LanMountainDesktop.PluginIsolation.Contracts`
职责:
- 纯 DTO
- 协议版本
- 路由常量
- 错误码
- capability 声明
- source-generated JSON context
约束:
- 不引用 Avalonia
- 不依赖 Host 服务实现
- 作为 Host、Worker、SDK 共享的传输边界
### 6.2 `LanMountainDesktop.PluginIsolation.Ipc`
职责:
- 对标 ClassIsland 的轻量 IPC 封装外壳
- 统一 `PluginIpcClient`
- 统一 `PluginIpcServer`
- 统一启动参数、环境变量、通知路由常量
约束:
- 借鉴 ClassIsland 的“包装层 + 常量集中 + 客户端低接入成本”
- 但不把插件系统主协议设计成大面积远程属性模型
### 6.3 `LanMountainDesktop.PluginSdk`
新增内容:
- `runtime.mode` Manifest 支持
- `PluginRuntimeMode`
- `IPluginWorker`
- `IPluginWorkerContext`
- `PluginWorkerBase`
- `[PluginWorkerEntrance]`
## 7. ClassIsland IPC 借鉴与取舍
参考资料:
- [ClassIsland 仓库](https://github.com/ClassIsland/ClassIsland)
- [ClassIsland.Shared.IPC/IpcClient.cs](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland.Shared.IPC/IpcClient.cs)
- [ClassIsland.Shared.IPC/IpcRoutedNotifyIds.cs](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland.Shared.IPC/IpcRoutedNotifyIds.cs)
- [ClassIsland.Shared.IPC Abstractions](https://github.com/ClassIsland/ClassIsland/tree/master/ClassIsland.Shared.IPC/Abstractions/Services)
借鉴点:
- IPC 能力独立成包,边界清晰。
- `IpcClient` 对底层库做轻量封装,接入成本低。
- 通知路由有集中定义,事件名稳定。
- 通过公共接口暴露很小的可用面,减少耦合。
不照搬的部分:
- 不把插件隔离主协议做成“远程对象/远程属性”模型。
- 不隐藏跨进程调用成本。
- 不让 UI 状态同步变成一串隐式属性访问。
最终结论:
- 采用“ClassIsland 风格的封装外壳”。
- 协议主线仍是显式路由和明确 DTO。
## 8. 迁移策略
### 8.1 Manifest
`plugin.json` 新增:
```json
{
"runtime": {
"mode": "in-proc"
}
}
```
默认值为 `in-proc`
### 8.2 插件迁移顺序
1. 保持现有 UI 注册方式不变。
2. 把后台任务和风险代码收敛到 Worker。
3. 让 Host UI 通过 `ui/*``settings/*` 路由访问 Worker 状态。
4. 在二期再评估 `isolated-window` 迁移。
## 9. 故障模型与残余风险
一期必须满足以下行为:
- Worker 启动失败时Host 仅禁用该插件并记录诊断。
- Worker 心跳超时或被强杀时Host 不崩溃。
- Worker 上报 `fault/report`Host 将插件标记为 degraded 或 faulted。
一期残余风险也必须明确写出:
- 如果 Host 仍执行插件提供的 UI 代码UI 级崩溃仍可能影响 Host。
- 因此 `isolated-background` 不是最终隔离形态,只是第一阶段收益最高的落点。
- 完整 UI 崩溃隔离依赖二期 `isolated-window`

View File

@@ -145,3 +145,38 @@ Update plugin manifests to API `4.x`:
- component registration migrated to options model - component registration migrated to options model
- runtime appearance access uses `IPluginAppearanceContext` - runtime appearance access uses `IPluginAppearanceContext`
- plugin package rebuilt and republished as `.laapp` - plugin package rebuilt and republished as `.laapp`
## Process Isolation Additions
SDK `4.x` now also reserves manifest and API surface for process isolation without breaking existing plugins.
### Manifest
`plugin.json` can declare the desired runtime mode:
```json
{
"runtime": {
"mode": "in-proc"
}
}
```
Supported values:
- `in-proc`
- `isolated-background`
- `isolated-window`
If `runtime` is omitted, the host normalizes it to `in-proc` for backward compatibility.
### Worker Entry
Plugins that opt into isolated execution can prepare a worker-side entry by implementing:
- `IPluginWorker`
- `PluginWorkerBase`
- `IPluginWorkerContext`
- `[PluginWorkerEntrance]`
The first phase only targets `isolated-background`: background services, timers, network calls, and risky native integrations move into the worker process, while UI remains a host-side shell driven over IPC.

View File

@@ -642,3 +642,40 @@ xattr -cr /Applications/LanMountainDesktop.app
- [Launcher 鏋舵瀯](LAUNCHER.md) - [Launcher 鏋舵瀯](LAUNCHER.md)
- [鏇存柊绯荤粺](UPDATE_SYSTEM.md) - [鏇存柊绯荤粺](UPDATE_SYSTEM.md)
- [鏋勫缓鍜岄儴缃瞉(BUILD_AND_DEPLOY.md) - [鏋勫缓鍜岄儴缃瞉(BUILD_AND_DEPLOY.md)
### 问题: OOBE 窗口重复出现
**原因:** OOBE 完成标记丢失、损坏,或者旧版标记文件只作为迁移兼容而不是主状态源。
**当前权威状态路径:**
```bash
Windows: %LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json
```
**处理原则:**
- 同一 Windows 用户重装或升级后,默认不应该再次进入 OOBE。
- `first_run_completed` 只保留为兼容迁移数据。
- 如果状态文件不可读Launcher 应优先保证稳定启动并记录 `oobe_state_unavailable`,不要反复把用户拉回 OOBE。
---
### 问题: 启动或插件安装意外弹出管理员权限
**原因:** 某些路径显式请求了 `runas`,或者流程把默认用户目录误判成需要提权。
**当前允许提权的白名单:**
- 安装器本体
- 全量安装包更新应用
- 用户显式确认的 legacy uninstall
**不应弹 UAC 的场景:**
- 普通冷启动
- OOBE
- 检查更新
- 增量下载
- 默认插件安装到用户 LocalAppData 路径
**调试建议:**
- 检查日志中的 `launchSource``isElevated``oobeStateStatus``oobeSuppressionReason`
- 检查插件安装目标是否仍在 `%LOCALAPPDATA%\LanMountainDesktop`
- 确认没有额外的 `Verb = "runas"` 被引入默认路径