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] OOBE and splash are implemented as separate windows.
- [x] Update and rollback logic use version directory markers.
- [ ] Treat `first_run_completed` as legacy-only compatibility data.
- [ ] Keep the authoritative OOBE state in `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.

View File

@@ -52,3 +52,9 @@ Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
- `IOobeStep` for future multi-step OOBE
- `ISplashStageReporter` for future startup progress visualization
## Compatibility Addendum
- The current production OOBE state format is a per-user JSON file at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
- `first_run_completed` remains legacy compatibility data only.
- Same-user reinstall or upgrade should not re-enter OOBE.

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()
{
// 初始化日志记录器
Logger.Initialize();
Logger.Info("Launcher starting...");
var context = LauncherRuntimeContext.Current;
var execution = LauncherExecutionContext.Capture();
Logger.Info(
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
$"IsPreview={context.IsPreviewCommand}; IsDebugMode={context.IsDebugMode}; " +
$"LaunchSource='{context.LaunchSource}'; IsElevated={execution.IsElevated}; " +
$"UserSid='{execution.UserSid ?? string.Empty}'; ExplicitAppRoot='{context.ExplicitAppRoot ?? "<none>"}'.");
AvaloniaXamlLoader.Load(this);
}
@@ -24,41 +29,31 @@ public partial class App : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var context = LauncherRuntimeContext.Current;
var execution = LauncherExecutionContext.Capture();
Logger.Info(
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
// 调试模式:显示开发调试窗口
if (context.IsDebugMode)
{
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
// 调试模式下不自动启动正常流程,由开发者通过调试窗口控制
base.OnFrameworkInitializationCompleted();
return;
}
// 处理各界面的预览命令
if (HandlePreviewCommand(context, desktop))
{
base.OnFrameworkInitializationCompleted();
return;
}
// apply-update 模式:显示 UpdateWindow执行增量更新 + 插件升级
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
// 先显示窗口,再启动后台任务
var updateWindow = new UpdateWindow();
updateWindow.Show();
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
}
else
{
// 先显示 Splash 窗口,确保应用程序不会立即退出
var splashWindow = new SplashWindow();
splashWindow.Show();
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
}
}
@@ -66,156 +61,128 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// 处理界面预览命令
/// </summary>
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
var command = context.Command.ToLowerInvariant();
switch (command)
switch (context.Command.ToLowerInvariant())
{
case "preview-splash":
Console.WriteLine("[Launcher] Preview mode: SplashWindow");
{
Logger.Info("Preview command: splash.");
var splashWindow = new SplashWindow();
splashWindow.SetDebugMode(true);
splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
return true;
}
case "preview-error":
Console.WriteLine("[Launcher] Preview mode: ErrorWindow");
{
Logger.Info("Preview command: error.");
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow);
return true;
}
case "preview-update":
Console.WriteLine("[Launcher] Preview mode: UpdateWindow");
{
Logger.Info("Preview command: update.");
var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true);
updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
return true;
}
case "preview-oobe":
Console.WriteLine("[Launcher] Preview mode: OobeWindow");
{
Logger.Info("Preview command: oobe.");
var oobeWindow = new OobeWindow();
oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
return true;
}
case "preview-debug":
Console.WriteLine("[Launcher] Preview mode: DevDebugWindow");
{
Logger.Info("Preview command: debug window.");
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
return true;
}
default:
return false;
}
}
/// <summary>
/// 模拟 Splash 窗口预览
/// </summary>
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
var messages = new[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" };
var reporter = (ISplashStageReporter)window;
for (int i = 0; i < stages.Length; i++)
for (var i = 0; i < stages.Length; i++)
{
reporter.Report(stages[i], messages[i]);
await Task.Delay(800);
await Task.Delay(800).ConfigureAwait(false);
}
// 等待5秒后自动关闭
await Task.Delay(5000);
await Task.Delay(5000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
/// <summary>
/// 模拟 Update 窗口预览
/// </summary>
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
for (int i = 0; i < stages.Length; i++)
for (var i = 0; i < stages.Length; i++)
{
window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
await Task.Delay(600);
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
await Task.Delay(600).ConfigureAwait(false);
}
window.ReportComplete(true, null);
// 等待3秒后自动关闭
await Task.Delay(3000);
await Task.Delay(3000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
string GetStageName(string stage) => stage switch
{
"verify" => "验证",
"extract" => "解压",
"apply" => "应用",
"plugins" => "升级插件",
"cleanup" => "清理",
_ => stage
};
}
/// <summary>
/// 模拟 OOBE 窗口预览
/// </summary>
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{
try
{
// 等待用户点击开始按钮
await window.WaitForEnterAsync();
Console.WriteLine("[Launcher] OOBE preview completed by user");
await window.WaitForEnterAsync().ConfigureAwait(false);
Logger.Info("OOBE preview completed by user.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
Logger.Error("OOBE preview failed.", ex);
}
// 用户点击后关闭应用程序
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
/// <summary>
/// 等待窗口关闭
/// </summary>
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{
var tcs = new TaskCompletionSource();
window.Closed += (s, e) => tcs.TrySetResult();
await tcs.Task;
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
window.Closed += (_, _) => tcs.TrySetResult();
await tcs.Task.ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task RunCoordinatorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
SplashWindow splashWindow)
{
LauncherResult result;
ErrorWindow? errorWindow = null;
LauncherFlowCoordinator? coordinator = null;
try
{
// 在 try-catch 块中实例化所有服务,确保异常被捕获
var appRoot = Commands.ResolveAppRoot(context);
Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
var deploymentLocator = new DeploymentLocator(appRoot);
// TODO: 从配置读取 GitHub 仓库信息
coordinator = new LauncherFlowCoordinator(
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
@@ -226,88 +193,85 @@ public partial class App : Application
}
catch (Exception ex)
{
// 捕获异常并显示错误窗口
Logger.Error("Coordinator threw an unhandled exception.", ex);
result = new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = $"启动器发生错误: {ex.Message}",
Message = $"Launcher failed: {ex.Message}",
ErrorMessage = ex.ToString()
};
Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
try
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
// 安全关闭 Splash 窗口
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception closeEx)
{
Console.Error.WriteLine($"[Launcher] Error closing splash window: {closeEx.Message}");
}
// 创建并显示错误窗口
try
{
errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage($"启动器发生错误:\n{ex.Message}\n\n请检查应用安装是否完整或尝试重新安装。");
errorWindow.Show();
Console.WriteLine("[Launcher] ErrorWindow shown successfully");
}
catch (Exception windowEx)
{
Console.Error.WriteLine($"[Launcher] Failed to show ErrorWindow: {windowEx.Message}");
}
});
// 如果错误窗口成功显示,等待它关闭
if (errorWindow != null)
{
try
{
// 等待用户选择或窗口关闭
var errorResult = await errorWindow.WaitForChoiceAsync();
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
}
catch (Exception waitEx)
{
Console.Error.WriteLine($"[Launcher] Error waiting for ErrorWindow: {waitEx.Message}");
// 如果等待失败至少给用户5秒时间看到错误信息
await Task.Delay(5000);
}
}
else
{
// 错误窗口未能显示等待5秒让用户看到控制台输出
await Task.Delay(5000);
}
}
catch (Exception uiEx)
{
// 最后的兜底:记录到控制台
Console.Error.WriteLine($"[Launcher] Critical error in UI thread: {uiEx.Message}");
await Task.Delay(3000);
}
}
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
if (!result.Success &&
result.Code is not "host_not_found" &&
(string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) ||
string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
{
await ShowFailureWindowAsync(result).ConfigureAwait(false);
}
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
/// <summary>
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
/// </summary>
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
{
var resultPath = context.GetOption("result");
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
try
{
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
}
catch (Exception ex)
{
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
}
}
private static async Task ShowFailureWindowAsync(LauncherResult result)
{
ErrorWindow? errorWindow = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage(
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
errorWindow.Show();
}
catch (Exception ex)
{
Logger.Error("Failed to show launcher failure window.", ex);
}
});
if (errorWindow is null)
{
return;
}
try
{
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Failure window closed unexpectedly.", ex);
}
}
private static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
@@ -324,8 +288,7 @@ public partial class App : Application
try
{
// 1. 应用增量更新
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
@@ -333,24 +296,20 @@ public partial class App : Application
errorMessage = updateResult.Message;
}
// 2. 应用待处理的插件升级
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60));
var pluginsDir = context.GetOption("plugins-dir")
?? Path.Combine(appRoot, "plugins");
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
{
// 插件升级失败不阻断整体流程,仅记录到控制台
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
}
}
// 3. 清理旧版本保留至少3个版本以支持回滚
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
@@ -358,33 +317,26 @@ public partial class App : Application
{
success = false;
errorMessage = ex.Message;
Logger.Error("Apply-update flow failed.", ex);
}
// 显示完成状态,短暂停留后关闭
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
if (success)
{
// 成功:停留 1.5 秒让用户看到"更新完成"
await Task.Delay(1500);
}
else
{
// 失败:停留 5 秒让用户看到错误信息
await Task.Delay(5000);
}
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{
Success = success,
Stage = "apply-update",
Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource
}
}).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
}

View File

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

View File

@@ -4,6 +4,19 @@ namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
private const string LaunchSourceOptionName = "launch-source";
private static readonly string[] GuiCommands =
[
"launch",
"apply-update",
"preview-splash",
"preview-error",
"preview-update",
"preview-oobe",
"preview-debug"
];
public string Command { get; }
public string SubCommand { get; }
@@ -20,6 +33,8 @@ internal sealed class CommandContext
Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result");
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
/// <summary>
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
/// 仅当明确指定 --debug 参数或调试器附加时才启用
@@ -28,6 +43,20 @@ internal sealed class CommandContext
Options.ContainsKey("debug") ||
System.Diagnostics.Debugger.IsAttached;
public bool IsPreviewCommand =>
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
public bool IsGuiCommand =>
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
public bool IsMaintenanceCommand =>
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
public string? ExplicitAppRoot => GetOption("app-root");
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
{
Command = command;
@@ -62,6 +91,44 @@ internal sealed class CommandContext
: fallback;
}
private string InferLaunchSource()
{
if (IsPreviewCommand)
{
return "debug-preview";
}
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
return "apply-update";
}
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
{
return "plugin-install";
}
return "normal";
}
private static string? NormalizeLaunchSource(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
return raw.Trim().ToLowerInvariant() switch
{
"normal" => "normal",
"postinstall" => "postinstall",
"apply-update" => "apply-update",
"plugin-install" => "plugin-install",
"debug-preview" => "debug-preview",
_ => null
};
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

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)
{
var commandContext = CommandContext.FromArgs(args);
var execution = LauncherExecutionContext.Capture();
Logger.Initialize();
Logger.Info(
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
$"LaunchSource='{commandContext.LaunchSource}'; IsElevated={execution.IsElevated}; " +
$"UserSid='{execution.UserSid ?? string.Empty}'; " +
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
// 处理遗留插件安装命令
if (commandContext.IsLegacyPluginInstall)
try
{
var installer = new PluginInstallerService();
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
}
if (commandContext.IsLegacyPluginInstall)
{
var installer = new PluginInstallerService();
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
}
if (!commandContext.IsGuiCommand)
{
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
}
// apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
// 处理其他 CLI 命令 (update, plugin, rollback 等)
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
catch (Exception ex)
{
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
}
Logger.Error("Launcher failed before GUI flow completed.", ex);
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
var result = new LauncherResult
{
Success = false,
Stage = "launcher",
Code = "launcher_bootstrap_failed",
Message = ex.Message,
ErrorMessage = ex.ToString(),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = commandContext.Command,
["subCommand"] = commandContext.SubCommand,
["launchSource"] = commandContext.LaunchSource,
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
["isElevated"] = execution.IsElevated.ToString(),
["userSid"] = execution.UserSid ?? string.Empty,
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? string.Empty
}
};
await Commands.WriteResultIfNeededAsync(commandContext.GetOption("result"), result).ConfigureAwait(false);
return 1;
}
}
private static AppBuilder BuildAvaloniaApp()

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

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 fileName = parts[0].Trim('"');
var arguments = parts.Length > 1 ? parts[1] : "";
Logger.Info(
$"Opening legacy uninstall interface with elevation reason 'legacy_uninstall'. " +
$"InstallPath='{info.InstallPath}'; Version='{info.Version}'.");
Process.Start(new ProcessStartInfo
{

View File

@@ -1,104 +1,221 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class OobeStateService
{
private readonly string _markerPath;
private const int CurrentSchemaVersion = 1;
public OobeStateService(string appRoot)
private readonly string _stateDirectory;
private readonly string _statePath;
private readonly string _legacyMarkerPath;
private readonly LauncherExecutionSnapshot _executionSnapshot;
public OobeStateService(
string appRoot,
string? stateRootOverride = null,
LauncherExecutionSnapshot? executionSnapshot = null)
{
// 优先使用 LocalApplicationData用户目录普通用户一定有权限
string? stateDir = null;
Exception? lastException = null;
_ = Path.GetFullPath(appRoot);
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
// 策略1: LocalApplicationData首选用户目录普通用户一定有写权限
try
{
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
stateDir = Path.Combine(appDataDir, ".launcher", "state");
Directory.CreateDirectory(stateDir);
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
stateDir = null;
}
// 策略2: 如果LocalApplicationData不行使用用户的临时目录
if (stateDir == null)
{
try
{
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
Directory.CreateDirectory(tempDir);
stateDir = tempDir;
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
stateDir = null;
}
}
// 策略3: 最后的兜底使用当前用户的应用程序数据目录和Launcher同目录
if (stateDir == null)
{
try
{
var launcherDir = AppContext.BaseDirectory;
stateDir = Path.Combine(launcherDir, ".launcher", "state");
Directory.CreateDirectory(stateDir);
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
// 如果所有策略都失败,抛出异常让上层处理
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
}
}
_markerPath = Path.Combine(stateDir, "first_run_completed");
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
? GetDefaultStateRoot()
: Path.GetFullPath(stateRootOverride);
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
}
public bool IsFirstRun()
public OobeLaunchDecision Evaluate(CommandContext context)
{
var decision = EvaluateCore(context);
Logger.Info(
$"OOBE decision evaluated. LaunchSource='{decision.LaunchSource}'; Status='{decision.Status}'; " +
$"ShouldShow={decision.ShouldShowOobe}; IsElevated={decision.IsElevated}; " +
$"StatePath='{decision.StatePath}'; SuppressionReason='{decision.SuppressionReason}'; " +
$"ResultCode='{decision.ResultCode}'; UserSid='{decision.UserSid ?? string.Empty}'.");
return decision;
}
public OobeCompletionResult MarkCompleted(CommandContext context)
{
try
{
return !File.Exists(_markerPath);
Directory.CreateDirectory(_stateDirectory);
var payload = new OobeStateFile
{
SchemaVersion = CurrentSchemaVersion,
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
UserName = _executionSnapshot.UserName,
UserSid = _executionSnapshot.UserSid,
LaunchSource = context.LaunchSource
};
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
File.WriteAllText(tempPath, json);
File.Move(tempPath, _statePath, overwrite: true);
TryDeleteLegacyMarker();
Logger.Info(
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
return new OobeCompletionResult
{
Success = true,
ResultCode = "ok"
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
// 如果无法检查默认视为首次运行确保OOBE能显示
return true;
Logger.Warn(
$"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
$"Error='{ex.Message}'.");
return new OobeCompletionResult
{
Success = false,
ResultCode = "oobe_state_unavailable",
ErrorMessage = ex.Message
};
}
}
public void MarkCompleted()
private OobeLaunchDecision EvaluateCore(CommandContext context)
{
if (string.Equals(context.LaunchSource, "debug-preview", StringComparison.OrdinalIgnoreCase))
{
return BuildSuppressedDecision(context, "debug_preview", "oobe_suppressed_debug_preview");
}
if (context.IsMaintenanceCommand)
{
return BuildSuppressedDecision(context, "maintenance", "oobe_suppressed_maintenance");
}
try
{
var dir = Path.GetDirectoryName(_markerPath);
if (!string.IsNullOrWhiteSpace(dir))
var migratedLegacyMarker = false;
if (File.Exists(_statePath))
{
Directory.CreateDirectory(dir);
using var stream = File.OpenRead(_statePath);
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
{
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
}
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: false);
}
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
Console.WriteLine("[OobeStateService] Marked first run as completed");
if (File.Exists(_legacyMarkerPath))
{
migratedLegacyMarker = TryMigrateLegacyMarker(context);
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
}
if (_executionSnapshot.IsElevated)
{
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
}
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
{
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
}
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
// 如果无法写入也没关系下次启动还会显示OOBE
return BuildUnavailableDecision(context, ex.Message);
}
}
private bool TryMigrateLegacyMarker(CommandContext context)
{
var result = MarkCompleted(context);
return result.Success;
}
private void TryDeleteLegacyMarker()
{
try
{
if (File.Exists(_legacyMarkerPath))
{
File.Delete(_legacyMarkerPath);
}
}
catch
{
}
}
private OobeLaunchDecision BuildDecision(
CommandContext context,
OobeStateStatus status,
bool shouldShowOobe,
bool usedLegacyMarker = false,
bool migratedLegacyMarker = false)
{
return new OobeLaunchDecision
{
Status = status,
ShouldShowOobe = shouldShowOobe,
StatePath = _statePath,
LaunchSource = context.LaunchSource,
IsElevated = _executionSnapshot.IsElevated,
UserName = _executionSnapshot.UserName,
UserSid = _executionSnapshot.UserSid,
UsedLegacyMarker = usedLegacyMarker,
MigratedLegacyMarker = migratedLegacyMarker,
ResultCode = "ok"
};
}
private OobeLaunchDecision BuildSuppressedDecision(CommandContext context, string reason, string resultCode)
{
return new OobeLaunchDecision
{
Status = OobeStateStatus.Suppressed,
ShouldShowOobe = false,
StatePath = _statePath,
LaunchSource = context.LaunchSource,
IsElevated = _executionSnapshot.IsElevated,
UserName = _executionSnapshot.UserName,
UserSid = _executionSnapshot.UserSid,
SuppressionReason = reason,
ResultCode = resultCode
};
}
private OobeLaunchDecision BuildUnavailableDecision(CommandContext context, string errorMessage)
{
return new OobeLaunchDecision
{
Status = OobeStateStatus.Unavailable,
ShouldShowOobe = false,
StatePath = _statePath,
LaunchSource = context.LaunchSource,
IsElevated = _executionSnapshot.IsElevated,
UserName = _executionSnapshot.UserName,
UserSid = _executionSnapshot.UserSid,
ResultCode = "oobe_state_unavailable",
ErrorMessage = errorMessage
};
}
private static string GetDefaultStateRoot()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(appData))
{
throw new InvalidOperationException("LocalApplicationData is unavailable.");
}
return Path.Combine(appData, "LanMountainDesktop");
}
}

View File

@@ -30,6 +30,11 @@ internal sealed class PluginInstallerService
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
{
return elevationRequiredResult;
}
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
@@ -51,6 +56,46 @@ internal sealed class PluginInstallerService
};
}
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
{
if (!OperatingSystem.IsWindows())
{
return null;
}
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
return null;
}
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
{
return null;
}
Logger.Warn(
$"Plugin installation requires explicit elevation. Reason='plugin_requires_elevation'; " +
$"PluginsDirectory='{pluginsDirectory}'; AllowedRoot='{allowedRoot}'.");
return new LauncherResult
{
Success = false,
Stage = "plugin.install",
Code = "plugin_elevation_required",
Message = "Plugin installation outside the current user's LanMountainDesktop data directory requires explicit elevation.",
ErrorMessage = "Plugin installation target is outside the current user's LanMountainDesktop data directory.",
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["pluginsDirectory"] = pluginsDirectory,
["allowedRoot"] = allowedRoot,
["elevationReason"] = "outside_user_scope"
}
};
}
public PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);

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

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="Microsoft.Extensions.DependencyInjection.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" />
</ItemGroup>

View File

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

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
- `IPlugin`/`PluginBase` entry abstractions
- `IPluginWorker`/`PluginWorkerBase` worker-side entry abstractions for isolated background mode
- `PluginManifest` and shared contract declarations
- `runtime.mode` manifest support for `in-proc`, `isolated-background`, and `isolated-window`
- desktop component registration extensions
- plugin runtime context and host service abstractions
- build-transitive packaging targets for `.laapp` output

View File

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

View File

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

View File

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

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>
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
</ItemGroup>
</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.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
<Project Path="LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj" />
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />

View File

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

View File

@@ -8,6 +8,7 @@ using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -33,6 +34,7 @@ public sealed class Program
AppLogger.Warn(
"Startup",
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
ReportLauncherStageBeforeExit(StartupStage.ActivationFailed, "Restart relaunch could not acquire the single-instance lock.");
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
return;
}
@@ -43,6 +45,7 @@ public sealed class Program
AppLogger.Info(
"Startup",
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
ReportLauncherStageBeforeExit(StartupStage.ActivationRedirected, "Secondary launch forwarded to the primary instance.");
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
}
else
@@ -50,6 +53,9 @@ public sealed class Program
AppLogger.Warn(
"Startup",
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
ReportLauncherStageBeforeExit(
StartupStage.ActivationFailed,
$"Secondary launch failed to activate the primary instance. Reason='{failureReason ?? "unknown"}'.");
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
}
@@ -247,6 +253,35 @@ public sealed class Program
};
}
private static void ReportLauncherStageBeforeExit(StartupStage stage, string message)
{
if (!LauncherIpcClient.IsLaunchedByLauncher())
{
return;
}
try
{
using var launcherIpcClient = new LauncherIpcClient();
var connected = launcherIpcClient.ConnectAsync().GetAwaiter().GetResult();
if (!connected)
{
return;
}
launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = stage,
ProgressPercent = 100,
Message = message
}).GetAwaiter().GetResult();
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report early launcher stage '{stage}'.", ex);
}
}
private static void InitializeTelemetryIdentity()
{
try

View File

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

View File

@@ -5,6 +5,7 @@ using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -28,7 +29,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult(
false,
null,
"Elevated helper install is only supported on Windows.");
"Elevated helper install is only supported on Windows.",
"failed");
}
var launcherPath = ResolveLauncherPath();
@@ -37,7 +39,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult(
false,
null,
$"Launcher executable was not found at '{launcherPath}'.");
$"Launcher executable was not found at '{launcherPath}'.",
"failed");
}
var resultPath = Path.Combine(
@@ -53,14 +56,18 @@ internal sealed class LauncherClient
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
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);
var result = await ReadResultAsync(resultPath, cancellationToken);
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)
@@ -68,7 +75,8 @@ internal sealed class LauncherClient
return new LauncherInstallResult(
false,
null,
"Launcher exited without producing a result file.");
"Launcher exited without producing a result file.",
"failed");
}
return new LauncherInstallResult(
@@ -77,11 +85,12 @@ internal sealed class LauncherClient
string.Format(
CultureInfo.InvariantCulture,
"Launcher exited with code {0}.",
process.ExitCode));
process.ExitCode),
"failed");
}
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
{
@@ -98,12 +107,11 @@ internal sealed class LauncherClient
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Verb = "runas",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
Arguments = string.Create(
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);
@@ -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
{
[JsonPropertyName("success")]
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; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}
}
@@ -183,4 +211,5 @@ internal sealed class LauncherClient
internal sealed record LauncherInstallResult(
bool Success,
string? InstalledPackagePath,
string? ErrorMessage);
string? ErrorMessage,
string Code);

View File

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

View File

@@ -243,7 +243,8 @@ internal sealed class AirAppMarketInstallService : IDisposable
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
AppLogger.Error(
"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);
}

View File

@@ -197,6 +197,35 @@ The runtime flow starts with the Launcher selecting the best version, then proce
## 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.
## 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)
- [架构文档](ARCHITECTURE.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
- runtime appearance access uses `IPluginAppearanceContext`
- 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)
- [鏇存柊绯荤粺](UPDATE_SYSTEM.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"` 被引入默认路径