diff --git a/.gitignore b/.gitignore
index 56e5892..8f095ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -518,3 +518,4 @@ nul
/velopack-output-local-verify
/velopack-output-local
/test-aot-publish
+/.claude/worktrees
diff --git a/.trae/documents/class-schedule-widget-redesign.md b/.trae/documents/class-schedule-widget-redesign.md
new file mode 100644
index 0000000..f687fae
--- /dev/null
+++ b/.trae/documents/class-schedule-widget-redesign.md
@@ -0,0 +1,403 @@
+# 课程表组件视觉重构 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 彻底重构阑山桌面的课程表(ClassScheduleWidget)组件视觉设计,参考小爱课程表的桌面小部件风格,实现时间轴+色块卡片布局、科目自动配色、当前课程进度高亮等现代化视觉效果。
+
+**Architecture:** 保留现有数据层(ClassIslandScheduleDataService、Models)和组件注册机制不变,仅重构 Widget 的 UI 渲染层(XAML + code-behind 中的渲染逻辑)。新增科目配色服务,为每门课程分配稳定的区分色。先创建 HTML Mock 验证视觉效果,再移植到 Avalonia XAML。
+
+**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览)
+
+---
+
+## 当前状态分析
+
+### 现有组件结构
+- **XAML**: `ClassScheduleWidget.axaml` — 仅定义了 RootBorder、HeaderGrid(日期+星期+课数)、ScrollViewer+CourseListPanel、StatusTextBlock
+- **Code-behind**: `ClassScheduleWidget.axaml.cs` — 所有课程项 UI 在 `CreateSingleItemControl()` 中手动构建:圆点(Bullet) + 文字栈(课程名/时间/详情)
+- **数据层**: `ClassIslandScheduleDataService` + `ClassIslandScheduleModels` — 不变
+- **编辑器**: `ClassScheduleComponentEditor.axaml(.cs)` — 不变
+
+### 现有设计问题
+1. **视觉单调**: 仅用小圆点区分课程,所有课程外观一致,缺乏层次感
+2. **信息密度低**: 课程名、时间、教师名挤在一行,可读性差
+3. **当前课不突出**: 仅通过圆点颜色变化标识当前课程,几乎无法一眼识别
+4. **色彩硬编码**: 颜色值直接写在 C# 中,不使用语义资源键,不遵循 VISUAL_SPEC
+5. **无时间轴感**: 列表式排列无法体现课程的时间先后和持续长度
+
+### 小爱课程表参考设计特征
+1. **时间轴布局**: 左侧显示时间刻度,右侧是课程色块卡片
+2. **科目配色**: 每门课程自动分配一种柔和区分色,卡片使用对应色块背景
+3. **当前课高亮**: 正在进行的课程有明显的视觉强调(放大/进度条/发光)
+4. **进度指示**: 当前课程显示上课进度(已过时间/总时长)
+5. **紧凑信息**: 课程名+教室/教师信息在色块内清晰排列
+6. **课间分隔**: 课间休息区域有视觉分隔(虚线/淡色区域)
+
+---
+
+## 设计方案
+
+### 视觉论文 (Visual Thesis)
+时间轴驱动的色块卡片布局,柔和科目配色,当前课程进度高亮——在桌面小组件有限空间内实现信息密度与美感的平衡。
+
+### 布局结构
+```
+┌─────────────────────────────────────┐
+│ 7/24 周一 今天3节课 │ ← 头部:日期 + 星期 + 课数
+├─────────────────────────────────────┤
+│ 08:00 ┌──────────────────────┐ │
+│ │ 语文 │ │ ← 科目色块卡片
+│ │ 王老师 · 教室301 │ │
+│ 08:45 └──────────────────────┘ │
+│ ┌──────────────────────┐ │
+│ │ 数学 ████████░░ 75% │ │ ← 当前课:进度条 + 高亮
+│ │ 李老师 · 教室205 │ │
+│ 09:30 └──────────────────────┘ │
+│ ... │
+└─────────────────────────────────────┘
+```
+
+### 科目配色方案
+使用一组预定义的柔和色彩,按科目名哈希值稳定分配:
+- 语文: #5B8FF9 (蓝)
+- 数学: #F6903D (橙)
+- 英语: #5AD8A6 (绿)
+- 物理: #E8684A (红)
+- 化学: #9270CA (紫)
+- 生物: #FF9845 (琥珀)
+- 历史: #1E9493 (青)
+- 地理: #FF99C3 (粉)
+- 政治: #7262FD (靛)
+- 体育: #78D3F8 (天蓝)
+- 默认: #8B95A5 (灰)
+
+### 当前课程高亮
+- 卡片左侧显示 3px 宽的强调色竖条
+- 卡片底部显示细进度条(已过时间/总时长)
+- 卡片背景使用科目色的 15% 透明度版本
+- 非当前课程使用科目色的 8% 透明度版本
+
+---
+
+## 文件变更清单
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml` | 修改 | 重构 XAML 布局:时间轴+卡片区域 |
+| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs` | 修改 | 重构渲染逻辑:色块卡片、科目配色、进度条 |
+| `LanMountainDesktop/Views/Components/SubjectColorService.cs` | 新建 | 科目配色服务:稳定哈希分配颜色 |
+| `mocks/class-schedule-mock.html` | 新建 | HTML Mock 预览(亮色+暗色) |
+
+---
+
+## Task 分解
+
+### Task 1: 创建 HTML Mock 预览
+
+**Files:**
+- Create: `mocks/class-schedule-mock.html`
+
+- [ ] **Step 1: 创建 HTML Mock 文件**
+
+创建完整的 HTML Mock,包含:
+- 亮色/暗色主题切换
+- 时间轴+色块卡片布局
+- 科目自动配色
+- 当前课程进度条高亮
+- 课间分隔区域
+- 响应式尺寸(模拟桌面组件 2x4 / 4x4 等尺寸)
+
+Mock 中应包含示例数据:
+```
+08:00-08:45 语文 王老师
+08:55-09:40 数学 李老师 (当前课,进度 60%)
+09:50-10:35 英语 张老师
+10:45-11:30 物理 赵老师
+14:00-14:45 化学 陈老师
+14:55-15:40 生物 刘老师
+```
+
+- [ ] **Step 2: 在浏览器中打开 Mock 验证效果**
+
+Run: `start mocks/class-schedule-mock.html`
+
+- [ ] **Step 3: 根据视觉效果调整 Mock 细节**
+
+调整间距、色值、字体大小、进度条样式等直到满意。
+
+---
+
+### Task 2: 创建科目配色服务
+
+**Files:**
+- Create: `LanMountainDesktop/Views/Components/SubjectColorService.cs`
+
+- [ ] **Step 1: 实现 SubjectColorService**
+
+```csharp
+using System;
+using Avalonia.Media;
+
+namespace LanMountainDesktop.Views.Components;
+
+internal static class SubjectColorService
+{
+ private static readonly (string Name, string Hex)[] Palette = [
+ ("语文", "#5B8FF9"),
+ ("数学", "#F6903D"),
+ ("英语", "#5AD8A6"),
+ ("物理", "#E8684A"),
+ ("化学", "#9270CA"),
+ ("生物", "#FF9845"),
+ ("历史", "#1E9493"),
+ ("地理", "#FF99C3"),
+ ("政治", "#7262FD"),
+ ("体育", "#78D3F8"),
+ ("音乐", "#F25E7E"),
+ ("美术", "#C2A1FD"),
+ ];
+
+ private static readonly string DefaultHex = "#8B95A5";
+
+ public static Color ResolveColor(string subjectName)
+ {
+ foreach (var (name, hex) in Palette)
+ {
+ if (subjectName.Contains(name, StringComparison.OrdinalIgnoreCase))
+ {
+ return Color.Parse(hex);
+ }
+ }
+
+ var hash = StableHash(subjectName);
+ var index = (int)(hash % (uint)Palette.Length);
+ return Color.Parse(Palette[index].Hex);
+ }
+
+ public static Color ResolveBackgroundColor(string subjectName, bool isCurrent, bool isNight)
+ {
+ var baseColor = ResolveColor(subjectName);
+ var alpha = isCurrent ? 0.18 : 0.08;
+ return new Color(
+ (byte)(alpha * 255),
+ baseColor.R,
+ baseColor.G,
+ baseColor.B);
+ }
+
+ public static Color ResolveForegroundColor(string subjectName, bool isNight)
+ {
+ var baseColor = ResolveColor(subjectName);
+ return isNight
+ ? new Color(0xFF, (byte)Math.Min(255, baseColor.R + 60), (byte)Math.Min(255, baseColor.G + 60), (byte)Math.Min(255, baseColor.B + 60))
+ : baseColor;
+ }
+
+ private static uint StableHash(string input)
+ {
+ uint hash = 5381;
+ foreach (var c in input)
+ {
+ hash = ((hash << 5) + hash) ^ (uint)c;
+ }
+ return hash;
+ }
+}
+```
+
+- [ ] **Step 2: 验证编译通过**
+
+Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug --no-restore`
+
+---
+
+### Task 3: 重构 ClassScheduleWidget XAML 布局
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml`
+
+- [ ] **Step 1: 重写 XAML 布局**
+
+新的 XAML 结构:
+- RootBorder 保持 `DesignCornerRadiusComponent`
+- 头部区域:日期(大号)+ 星期 + 课数 + 进度摘要
+- 课程列表区域:ScrollViewer 包裹 StackPanel
+- 每个 CourseItem 将在 code-behind 中构建为:Grid(时间列 + 卡片列)
+ - 时间列:StartTime / EndTime 垂直排列
+ - 卡片列:Border(科目色背景) > StackPanel(课程名 + 教师信息 + 进度条)
+
+XAML 只定义骨架,课程项仍由 code-behind 动态构建(因为需要科目配色和进度计算)。
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+### Task 4: 重构 ClassScheduleWidget 渲染逻辑
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs`
+
+- [ ] **Step 1: 扩展 CourseItemViewModel**
+
+在现有 record 中增加字段:
+
+```csharp
+private sealed record CourseItemViewModel(
+ string Name,
+ string TimeRange,
+ string Detail,
+ bool IsCurrent,
+ TimeSpan StartTime,
+ TimeSpan EndTime,
+ double Progress);
+```
+
+- [ ] **Step 2: 修改 BuildCourseItemViewModels 计算进度**
+
+在构建 ViewModel 时,对当前课程计算 Progress = (now - startTime) / (endTime - startTime)。
+
+- [ ] **Step 3: 重写 CreateSingleItemControl**
+
+新的课程项 UI 结构:
+
+```
+Grid (2列: 时间列 Auto + 卡片列 *)
+├── StackPanel (时间列)
+│ ├── TextBlock (开始时间, 如 "08:00")
+│ └── TextBlock (结束时间, 如 "08:45", 较淡)
+└── Border (卡片列, 科目色背景, 圆角 DesignCornerRadiusSm)
+ ├── 左侧强调竖条 (当前课显示, 3px宽, 科目色)
+ └── StackPanel
+ ├── TextBlock (课程名, 科目色前景, 加粗)
+ ├── TextBlock (教师/教室, 次要色)
+ └── ProgressBar (当前课显示, 科目色)
+```
+
+关键改动点:
+1. 移除圆点(Bullet),改用时间轴左侧时间标签
+2. 课程卡片使用 `SubjectColorService` 配色
+3. 当前课程卡片左侧显示强调竖条 + 底部进度条
+4. 课间区域用淡色分隔线标识
+5. 颜色使用语义资源键(`AdaptiveTextPrimaryBrush` 等),科目色通过 `SubjectColorService` 获取
+
+- [ ] **Step 4: 重写 ApplyAdaptiveLayout**
+
+更新自适应布局逻辑:
+- 头部日期/星期/课数徽章的字号和间距
+- 移除旧的圆点、文字栈相关计算
+- 新增时间列宽度、卡片圆角、进度条高度等计算
+- 使用 `ComponentChromeCornerRadiusHelper` 获取圆角 Token
+
+- [ ] **Step 5: 更新 IncrementalUpdateItems 和 IncrementalUpdateCurrentCourseHighlight**
+
+适配新的 UI 结构:
+- 更新进度条值
+- 更新科目色背景
+- 更新强调竖条可见性
+
+- [ ] **Step 6: 更新 RefreshSchedule 中的时间计算**
+
+在 `BuildCourseItemViewModels` 中传入 `StartTime`/`EndTime`/`Progress`。
+
+- [ ] **Step 7: 验证编译通过**
+
+Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug`
+
+---
+
+### Task 5: 验证与测试
+
+- [ ] **Step 1: 运行项目查看效果**
+
+Run: `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`
+
+- [ ] **Step 2: 运行相关测试**
+
+Run: `dotnet test LanMountainDesktop.slnx -c Debug`
+
+- [ ] **Step 3: 检查圆角规范合规**
+
+确认 RootBorder 使用 `DesignCornerRadiusComponent`,内部卡片使用 `DesignCornerRadiusSm`/`DesignCornerRadiusMd`,无硬编码圆角值。
+
+---
+
+## 假设与决策
+
+1. **科目配色**: 使用预定义调色板 + 哈希回退,不依赖 ClassIsland 数据中的科目颜色(因为 ClassIsland 不提供科目颜色字段)
+2. **进度条**: 仅当前课程显示进度条,非当前课程不显示
+3. **课间分隔**: 用 4px 间距 + 可选的淡色虚线分隔,不做复杂的课间休息区域
+4. **Mock 优先**: 先完成 HTML Mock 确认视觉效果,再实现 Avalonia 代码
+5. **编辑器不变**: ClassScheduleComponentEditor 不需要修改
+6. **数据层不变**: ClassIslandScheduleDataService 和 Models 不需要修改
+7. **接口兼容**: IDesktopComponentWidget、ITimeZoneAwareComponentWidget、IComponentPlacementContextAware 接口实现不变
+
+## 验证步骤
+
+1. HTML Mock 在浏览器中展示效果满意
+2. Avalonia 项目编译通过
+3. 运行项目,课程表组件显示新布局
+4. 亮色/暗色主题切换正常
+5. 当前课程高亮和进度条正常
+6. 科目配色稳定(同一科目每次显示颜色一致)
+7. 测试通过
diff --git a/.trae/documents/launcher_improved_plan_v2.md b/.trae/documents/launcher_improved_plan_v2.md
index b9874c5..0541a50 100644
--- a/.trae/documents/launcher_improved_plan_v2.md
+++ b/.trae/documents/launcher_improved_plan_v2.md
@@ -154,7 +154,7 @@
│ │
│ 方案 2: 命名管道(推荐用于进度报告) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
-│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
+│ │ [历史方案] Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
│ │ 主程序连接并发送进度消息 │ │
│ │ │ │
│ │ 消息格式: JSON │ │
@@ -289,7 +289,7 @@ public static class LauncherIpcConstants
#### 4. 实现 IPC 服务端
-**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
+**历史方案,已废弃**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
```csharp
using System.IO.Pipes;
@@ -428,7 +428,7 @@ public async Task RunAsync()
#### 6. 实现 IPC 客户端
-**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
+**历史方案,已废弃**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
```csharp
using System.IO.Pipes;
@@ -672,8 +672,8 @@ public class UpdateInstallationService
### 新增文件
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
-2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
-3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
+2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - 历史启动进度 IPC 服务端,已由公共 IPC 通知替代
+3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - 历史启动进度 IPC 客户端,已由公共 IPC 通知替代
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
### 删除文件
@@ -715,3 +715,11 @@ public class UpdateInstallationService
- [ ] GitHub Actions 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常
+
+## 2026 Multi-instance Policy Update
+
+- The old launcher progress pipe is historical only; current startup progress uses public IPC.
+- Launcher now reads Host `settings.json` for `MultiInstanceLaunchBehavior` before normal launch.
+- Existing Host behavior is policy-driven: restart app, open desktop silently, prompt only, or notify and open desktop.
+- Host no longer owns the single-instance listener or already-running prompt; repeated-launch policy lives in Launcher.
+- The repeated-launch prompt is a Fluent Launcher window; Host public IPC only exposes execution actions such as activate, restart, and exit.
diff --git a/.trae/documents/update-settings-redesign.md b/.trae/documents/update-settings-redesign.md
new file mode 100644
index 0000000..c049ec5
--- /dev/null
+++ b/.trae/documents/update-settings-redesign.md
@@ -0,0 +1,212 @@
+# 更新设置界面重设计实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将更新设置页面从丑陋的卡片堆叠布局重设计为遵循 Fluent Design 的 FASettingsExpander 列表布局,与项目其他设置页面保持视觉一致性。
+
+**Architecture:** 移除所有 `Border.settings-section-card` 包裹,改用 `FASettingsExpander` + `IconText` 分节标题 + `Separator` 分隔线的统一模式。操作按钮改为仅显示当前可用操作。版本信息改为 `FASettingsExpanderItem` 行项目展示。ViewModel 层新增 `ActiveActions` 计算属性来驱动按钮可见性。
+
+**Tech Stack:** Avalonia UI 11, FluentAvalonia 2.x, CommunityToolkit.Mvvm
+
+---
+
+## 当前状态分析
+
+### 现有文件
+
+| 文件 | 职责 |
+|------|------|
+| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` | 更新页面 AXAML 布局 |
+| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs` | 代码隐藏 |
+| `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` | 视图模型 |
+| `LanMountainDesktop/Styles/SettingsCardStyles.axaml` | 通用设置样式 |
+| `LanMountainDesktop/Controls/IconText.axaml(.cs)` | 分节标题控件 |
+| `LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs` | UpdatePhase 枚举和扩展方法 |
+
+### 核心问题
+
+1. **4 个 `Border.settings-section-card` 卡片**:状态卡、版本信息卡、进度卡、操作卡,每个都带边框+阴影+圆角,视觉零碎
+2. **FAInfoBar 嵌套在卡片内**:冗余的容器层级
+3. **7 个按钮 3×3 网格**:大量按钮在当前阶段不可用但仍然占据空间
+4. **与其他设置页面风格不一致**:GeneralSettingsPage、AppearanceSettingsPage 等全部使用 `FASettingsExpander` 列表
+
+### 参考基准
+
+- [GeneralSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml):`IconText` 分节标题 → `FASettingsExpander` 列表 → `Separator` 分隔
+- [AppearanceSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml):同上模式
+- [AboutSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml):`FAInfoBar` 用于静态信息展示
+- Windows 11 设置 > Windows Update:顶部状态区 + 进度条 + 操作按钮,下方展开区展示详情
+
+---
+
+## 设计决策
+
+| 决策项 | 选择 | 理由 |
+|--------|------|------|
+| 布局模式 | FASettingsExpander 列表 | 与其他设置页面统一,Fluent Design 原生控件 |
+| 按钮策略 | 仅显示可用操作 | 简洁、不混乱,Windows 11 更新页面也是此模式 |
+| 版本信息 | FASettingsExpanderItem 行项目 | 每行一个信息,干净可扫描 |
+| 进度展示 | 内嵌在状态 Expander 内 | 进度是状态的一部分,不应独立成卡 |
+| 偏好设置 | 保留 FASettingsExpander | 已经是正确模式,微调即可 |
+
+---
+
+## 新布局结构
+
+```
+ScrollViewer
+└── StackPanel (settings-page-container settings-page-animated)
+ ├── TextBlock (settings-section-title: "更新")
+ ├── TextBlock (settings-section-description: 描述文字)
+ │
+ ├── IconText (Icon="ArrowSync", Text="更新状态")
+ │
+ ├── FASettingsExpander "检查更新" (IsClickEnabled=True, Command=CheckCommand)
+ │ ├── IconSource: ArrowSync 图标
+ │ └── Footer: Button "检查更新" (仅 CanCheck 时可见)
+ │
+ ├── FASettingsExpander "更新进度" (IsVisible=IsBusy||IsProgressVisible||IsPaused)
+ │ ├── IconSource: FAProgressRing / 对应阶段图标
+ │ ├── Footer: PhaseText + ProgressFraction
+ │ └── FASettingsExpanderItem
+ │ ├── ProgressBar (ProgressFraction)
+ │ ├── ProgressDetail 文字
+ │ └── 操作按钮行 (仅可用操作)
+ │ ├── Button "下载" (CanDownload)
+ │ ├── Button "安装" (CanInstall)
+ │ ├── Button "暂停" (CanPause)
+ │ ├── Button "继续" (CanResume)
+ │ ├── Button "回滚" (CanRollback)
+ │ └── Button "取消" (CanCancel)
+ │
+ ├── FASettingsExpander "暂停" (IsVisible=IsPaused)
+ │ └── FAInfoBar (PausedBadgeText + PausedHintText)
+ │
+ ├── Separator (settings-separator)
+ │
+ ├── IconText (Icon="Info", Text="版本信息")
+ │
+ ├── FASettingsExpander "当前版本" (IsClickEnabled=False)
+ │ ├── IconSource: 版本图标
+ │ └── Footer: CurrentVersionText
+ │
+ ├── FASettingsExpander "最新版本" (IsClickEnabled=False)
+ │ ├── IconSource: 下载图标
+ │ └── Footer: LatestVersionText (或 "已是最新")
+ │
+ ├── FASettingsExpander "发布时间" (IsClickEnabled=False)
+ │ ├── IconSource: 日历图标
+ │ └── Footer: PublishedAtText
+ │
+ ├── FASettingsExpander "上次检查" (IsClickEnabled=False)
+ │ ├── IconSource: 时钟图标
+ │ └── Footer: LastCheckedText
+ │
+ ├── FASettingsExpander "更新类型" (IsClickEnabled=False)
+ │ ├── IconSource: 标签图标
+ │ └── Footer: UpdateTypeText
+ │
+ ├── Separator (settings-separator)
+ │
+ ├── IconText (Icon="Settings", Text="更新偏好")
+ │
+ └── FASettingsExpander "更新偏好" (IsExpanded=True)
+ ├── IconSource: 设置齿轮图标
+ ├── FASettingsExpanderItem "更新频道"
+ │ └── Footer: ComboBox (stable/preview)
+ ├── FASettingsExpanderItem "下载源"
+ │ └── Footer: ComboBox (plonds/github/proxy)
+ ├── FASettingsExpanderItem "更新模式"
+ │ └── Footer: ComboBox (manual/confirm/silent)
+ └── FASettingsExpanderItem "下载线程数"
+ └── Footer: Slider + TextBlock
+```
+
+---
+
+## Proposed Changes
+
+### Task 1: 重写 UpdateSettingsPage.axaml 布局
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml`
+
+**What:** 完全重写 AXAML,将 4 个 `Border.settings-section-card` 替换为 `FASettingsExpander` 列表布局。
+
+**Key changes:**
+1. 移除所有 `Border.settings-section-card` 包裹
+2. 使用 `controls:IconText` 做分节标题(与 GeneralSettingsPage 一致)
+3. 状态区域:`FASettingsExpander` + `IsClickEnabled=True` + `Command=CheckCommand`,Footer 放检查按钮
+4. 进度区域:`FASettingsExpander` 内嵌 ProgressBar + 操作按钮,仅 `IsBusy||IsProgressVisible||IsPaused` 时可见
+5. 版本信息:每个字段一个 `FASettingsExpander`,Footer 直接显示值(参考 Windows 11 更新页面的行项目模式)
+6. 偏好设置:保留 `FASettingsExpander` + `FASettingsExpanderItem` 模式,但将 TextBox 改为 ComboBox(更符合 Fluent 规范)
+7. 使用 `Separator classes="settings-separator"` 分隔三大区域
+
+**Why:** 与项目其他设置页面统一风格,遵循 Fluent Design,消除卡片堆叠的视觉噪音。
+
+**How:**
+- 参照 GeneralSettingsPage.axaml 的布局模式
+- 参照 AppearanceSettingsPage.axaml 的 FASettingsExpander 使用方式
+- 参照 AboutSettingsPage.axaml 的 FAInfoBar 使用方式
+
+### Task 2: 更新 ViewModel — 添加 ComboBox 数据源和按钮可见性属性
+
+**Files:**
+- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs`
+
+**What:**
+1. 将更新频道、下载源、更新模式从 `TextBox` 绑定改为 `ComboBox` 绑定,添加 `ObservableCollection` 类型的数据源属性
+2. 添加 `IsProgressSectionVisible` 计算属性(`IsBusy || IsProgressVisible || IsPaused`)
+3. 添加 `IsUpdateAvailableSectionVisible` 计算属性(`IsUpdateAvailable`)
+4. 添加 `IsStatusInfoVisible` 计算属性(有 StatusMessage 且非空闲时)
+5. 移除不再需要的独立按钮文本属性(CheckButtonText 保留,其他按钮文本属性保留但仅在可见时使用)
+
+**Why:** ComboBox 比 TextBox 更适合有限选项的输入,且与 GeneralSettingsPage 的模式一致。按钮可见性属性让 AXAML 可以用 `IsVisible` 绑定控制按钮显示。
+
+**How:**
+- 参考 GeneralSettingsPageViewModel 中 SelectionOption 的使用方式
+- 在 `OnCurrentPhaseChanged` 中触发新属性的 OnPropertyChanged
+
+### Task 3: 将偏好设置 TextBox 替换为 ComboBox
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` (在 Task 1 中一并完成)
+- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` (在 Task 2 中一并完成)
+
+**What:** 将更新频道、下载源、更新模式三个 `TextBox` 替换为 `ComboBox`,使用 `SelectionOption` 数据模板。
+
+**Why:** 有限选项应使用 ComboBox 而非自由文本输入,这是 Fluent Design 的基本规范,也与 GeneralSettingsPage 中的语言/时区选择一致。
+
+### Task 4: 构建验证
+
+**Files:**
+- 无新文件
+
+**What:** 运行 `dotnet build` 确保编译通过,检查 AXAML 绑定是否正确。
+
+---
+
+## Assumptions & Decisions
+
+1. **不修改 UpdateOrchestrator 和 UpdateState** — 只改 UI 层和 ViewModel 的展示逻辑,不改底层更新引擎
+2. **不修改 SettingsCardStyles.axaml** — 通用样式保持不变,移除的是 UpdateSettingsPage 对它的使用
+3. **保留所有 ViewModel 属性** — 即使某些属性在新布局中不再直接使用(如独立的 ActionsTitle),也保留以避免破坏本地化系统
+4. **ComboBox 选项硬编码在 ViewModel** — 参考 GeneralSettingsPageViewModel 的 SelectionOption 模式
+5. **进度区域在空闲时隐藏** — 不显示空的进度条,只在有活动时展示
+6. **FAInfoBar 仅用于暂停/错误提示** — 不再嵌套在卡片内,直接放在 FASettingsExpanderItem 内
+
+---
+
+## Verification Steps
+
+1. `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过
+2. 运行应用,导航到设置 > 更新页面,验证:
+ - 页面布局与 GeneralSettingsPage 风格一致
+ - 无圆角矩形卡片包裹
+ - 检查更新按钮可用
+ - 进度区域在空闲时隐藏
+ - 版本信息以行项目形式展示
+ - 偏好设置使用 ComboBox
+ - 操作按钮仅显示当前可用的
+3. 点击「检查更新」,验证状态变化和进度展示
+4. 验证偏好设置的 ComboBox 选择能正确保存和加载
diff --git a/.trae/specs/launcher-shell-hardening/spec.md b/.trae/specs/launcher-shell-hardening/spec.md
index 2e724e6..d73c365 100644
--- a/.trae/specs/launcher-shell-hardening/spec.md
+++ b/.trae/specs/launcher-shell-hardening/spec.md
@@ -65,3 +65,19 @@
- 托盘失败时应用仍保持可恢复。
- Launcher 与应用设置页显示相同版本。
- 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口入场、通知位置与动画正常。
+
+### 5. Launcher IPC and error surface follow-up
+
+- The legacy `LanMountainDesktop_Launcher` named-pipe startup progress channel is retired. Public IPC notifications and host exit codes are the only startup state sources.
+- Normal Launcher launches must probe public IPC for an existing Host before starting a new Host process. Host no longer owns multi-instance policy, activation prompts, or the old single-instance pipe.
+- `SecondaryActivationSucceeded` is a success terminal state. `SecondaryActivationFailed` and `RestartLockNotAcquired` may surface as failures only after public IPC recovery has failed.
+- Launcher startup errors must use FluentAvalonia resources, Fluent icons, an InfoBar recovery hint, and copyable diagnostics instead of the old hard-coded dark panel.
+
+### 6. Multi-instance behavior setting
+
+- App settings include `MultiInstanceLaunchBehavior` with default `NotifyAndOpenDesktop`.
+- General settings exposes the behavior under Basic Settings with four choices: restart app, open desktop silently, prompt only, and notify plus open desktop.
+- Launcher reads the Host `settings.json` before a normal launch and applies the selected behavior when public IPC reports an existing Host.
+- `PromptOnly` shows a Fluent Launcher prompt and does not open the desktop automatically.
+- `NotifyAndOpenDesktop` activates the existing Host and shows the already-running notice from Launcher.
+- `RestartApp` requests restart through public IPC and must not create a second Host if the restart request fails.
diff --git a/.trae/specs/launcher-shell-hardening/tasks.md b/.trae/specs/launcher-shell-hardening/tasks.md
index 9644627..b139320 100644
--- a/.trae/specs/launcher-shell-hardening/tasks.md
+++ b/.trae/specs/launcher-shell-hardening/tasks.md
@@ -12,3 +12,10 @@
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
- [x] 补充规格与版本同步说明文档。
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
+
+- [x] Remove the legacy `LanMountainDesktop_Launcher` startup progress pipe; launcher progress now uses public IPC plus host exit-code classification only.
+- [x] Move normal multi-open probing into Launcher before host launch and remove Host-side single-instance prompt/listener code.
+- [x] Refresh the Launcher error window with Fluent resources, InfoBar, Fluent icons, command bar actions, and copyable diagnostic details.
+- [x] Add app-level `MultiInstanceLaunchBehavior` setting and expose it in General > Basic Settings.
+- [x] Make Launcher apply restart/open silently/prompt only/notify and open behavior before starting a new Host.
+- [x] Add a Fluent Launcher multi-instance prompt; Host public IPC stays limited to activation/status/restart/exit actions.
diff --git a/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md
index 3067a0f..eb31ed1 100644
--- a/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md
+++ b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md
@@ -4,14 +4,14 @@
- Tray menu `Exit App` must commit an irreversible host shutdown request.
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
-- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
+- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, and telemetry resources before the forced-exit deadline.
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
## Acceptance
-- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
+- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to perform multi-instance detection through public IPC.
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
- Repeated tray clicks during shutdown are ignored and logged.
- Repeated component-library clicks focus the existing window instead of opening duplicates.
diff --git a/LanMountainDesktop.Launcher/App.axaml b/LanMountainDesktop.Launcher/App.axaml
index 3fc30e9..7b0db10 100644
--- a/LanMountainDesktop.Launcher/App.axaml
+++ b/LanMountainDesktop.Launcher/App.axaml
@@ -5,5 +5,8 @@
RequestedThemeVariant="Default">
+
diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs
index 8405ee3..306c087 100644
--- a/LanMountainDesktop.Launcher/App.axaml.cs
+++ b/LanMountainDesktop.Launcher/App.axaml.cs
@@ -112,6 +112,15 @@ public partial class App : Application
_ = WaitForWindowCloseAsync(desktop, errorWindow);
return true;
}
+ case "preview-multi-instance":
+ {
+ Logger.Info("Preview command: multi-instance prompt.");
+ var promptWindow = new MultiInstancePromptWindow();
+ promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop");
+ promptWindow.Show();
+ _ = WaitForWindowCloseAsync(desktop, promptWindow);
+ return true;
+ }
case "preview-update":
{
Logger.Info("Preview command: update.");
diff --git a/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs b/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs
index 04a4b9b..8c69ba5 100644
--- a/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs
+++ b/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs
@@ -16,6 +16,7 @@ public static class HostAppSettingsOobeMerger
public const string EnableFusedDesktopKey = "EnableFusedDesktop";
public const string EnableThreeFingerSwipeKey = "EnableThreeFingerSwipe";
public const string AutoStartWithWindowsKey = "AutoStartWithWindows";
+ public const string MultiInstanceLaunchBehaviorKey = "MultiInstanceLaunchBehavior";
public static string GetSettingsFilePath(string dataRoot) =>
Path.Combine(Path.GetFullPath(dataRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)), "settings.json");
@@ -54,6 +55,30 @@ public static class HostAppSettingsOobeMerger
}
}
+ public static MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior(string settingsPath)
+ {
+ if (!File.Exists(settingsPath))
+ {
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+
+ try
+ {
+ var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject();
+ if (root is null)
+ {
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+
+ return ReadMultiInstanceLaunchBehavior(root);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"HostAppSettingsOobeMerger: failed to read multi-instance behavior from '{settingsPath}'. {ex.Message}");
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+ }
+
public static void MergeStartupPresentation(string settingsPath, HostAppSettingsStartupChoices choices)
{
var directory = Path.GetDirectoryName(settingsPath);
@@ -109,6 +134,31 @@ public static class HostAppSettingsOobeMerger
_ => defaultValue
};
}
+
+ private static MultiInstanceLaunchBehavior ReadMultiInstanceLaunchBehavior(JsonObject root)
+ {
+ if (!root.TryGetPropertyValue(MultiInstanceLaunchBehaviorKey, out var node) || node is null)
+ {
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+
+ if (node is JsonValue value)
+ {
+ if (value.TryGetValue(out var text) &&
+ Enum.TryParse(text, ignoreCase: true, out var parsed))
+ {
+ return parsed;
+ }
+
+ if (value.TryGetValue(out var numeric) &&
+ Enum.IsDefined(typeof(MultiInstanceLaunchBehavior), numeric))
+ {
+ return (MultiInstanceLaunchBehavior)numeric;
+ }
+ }
+
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
}
public readonly record struct HostAppSettingsStartupDefaults(
diff --git a/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs b/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
deleted file mode 100644
index 2d4e447..0000000
--- a/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
+++ /dev/null
@@ -1,210 +0,0 @@
-using System.Buffers;
-using System.IO.Pipes;
-using System.Text.Json;
-using LanMountainDesktop.Shared.Contracts.Launcher;
-
-namespace LanMountainDesktop.Launcher.Services.Ipc;
-
-///
-/// Launcher IPC 服务端 - 接收主程序的启动进度报告
-/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
-/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
-///
-public class LauncherIpcServer : IDisposable
-{
- private readonly CancellationTokenSource _cts = new();
- private readonly Action _onProgress;
- private Task? _listenTask;
- private NamedPipeServerStream? _currentPipe;
-
- ///
- /// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
- /// 这在 Windows Message 模式和 unix Byte 模式下均能可靠工作。
- ///
- private const int LengthPrefixSize = 4;
-
- private const int BackoffBaseMs = 200;
- private const int BackoffMaxMs = 5000;
- private const int BackoffJitterMs = 100;
-
- public LauncherIpcServer(Action onProgress)
- {
- _onProgress = onProgress;
- }
-
- ///
- /// 启动 IPC 服务端监听
- ///
- public void Start()
- {
- _listenTask = Task.Run(ListenLoopAsync, _cts.Token);
- }
-
- private async Task ListenLoopAsync()
- {
- var consecutiveErrors = 0;
-
- while (!_cts.Token.IsCancellationRequested)
- {
- NamedPipeServerStream? pipe = null;
- try
- {
- pipe = new NamedPipeServerStream(
- LauncherIpcConstants.PipeName,
- PipeDirection.In,
- 1,
- PipeTransmissionMode.Byte,
- PipeOptions.Asynchronous);
-
- _currentPipe = pipe;
- await pipe.WaitForConnectionAsync(_cts.Token);
-
- consecutiveErrors = 0;
-
- await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (IOException)
- {
- consecutiveErrors = 0;
- continue;
- }
- catch (ObjectDisposedException)
- {
- break;
- }
- catch (Exception ex)
- {
- consecutiveErrors++;
- var delay = ComputeBackoff(consecutiveErrors);
- Console.Error.WriteLine($"IPC listen error (attempt {consecutiveErrors}), retrying in {delay}ms: {ex.Message}");
- try
- {
- await Task.Delay(delay, _cts.Token);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- }
- finally
- {
- try
- {
- pipe?.Dispose();
- }
- catch { }
-
- if (ReferenceEquals(_currentPipe, pipe))
- {
- _currentPipe = null;
- }
- }
- }
- }
-
- private int ComputeBackoff(int attempt)
- {
- var exponential = BackoffBaseMs * (1 << Math.Min(attempt - 1, 5));
- var capped = Math.Min(exponential, BackoffMaxMs);
- var jitter = Random.Shared.Next(0, BackoffJitterMs);
- return capped + jitter;
- }
-
- ///
- /// 从已连接的管道中持续读取消息,直到连接断开或取消
- ///
- private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
- {
- var lengthBuffer = ArrayPool.Shared.Rent(LengthPrefixSize);
- try
- {
- while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
- {
- // 1. 读取 4 字节长度前缀
- var totalRead = 0;
- while (totalRead < LengthPrefixSize)
- {
- var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
- if (read == 0)
- {
- // 连接已关闭
- return;
- }
- totalRead += read;
- }
-
- var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
- if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
- {
- // 无效长度,跳过此连接
- return;
- }
-
- // 2. 读取消息正文
- var payloadBuffer = ArrayPool.Shared.Rent(payloadLength);
- try
- {
- totalRead = 0;
- while (totalRead < payloadLength)
- {
- var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
- if (read == 0)
- {
- return;
- }
- totalRead += read;
- }
-
- // 3. 反序列化并回调
- var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
- var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
- if (message is not null)
- {
- _onProgress(message);
- }
- }
- catch (JsonException)
- {
- // 忽略解析错误,继续读取下一条消息
- }
- finally
- {
- ArrayPool.Shared.Return(payloadBuffer);
- }
- }
- }
- finally
- {
- ArrayPool.Shared.Return(lengthBuffer);
- }
- }
-
- ///
- /// 停止 IPC 服务端
- ///
- public void Stop()
- {
- _cts.Cancel();
- try
- {
- _currentPipe?.Dispose();
- }
- catch { }
- }
-
- public void Dispose()
- {
- Stop();
- _cts.Dispose();
-
- try
- {
- _listenTask?.Wait(TimeSpan.FromSeconds(2));
- }
- catch { }
- }
-}
diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
index c598042..0b93f89 100644
--- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
+++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
@@ -218,56 +218,53 @@ internal sealed class LauncherFlowCoordinator
{
if (ShouldProbeExistingHostBeforeLaunch(_context))
{
- var existingActivation = await TryActivateExistingHostWithStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
+ var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior();
+ var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
.ConfigureAwait(false);
- if (existingActivation is not null)
+ if (IsExistingHostReadyForLauncherDecision(existingShellStatus))
{
ipcConnected = true;
- shellStatus = existingActivation.Status;
- var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
- lastStage = existingActivation.Accepted
+ shellStatus = existingShellStatus;
+ var decisionResult = await ApplyExistingHostBehaviorAsync(
+ ipcClient,
+ multiInstanceBehavior,
+ existingShellStatus!)
+ .ConfigureAwait(false);
+ shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
+ var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
+ IsRecoverableActivationFailure(decisionResult.ActivationResult);
+ lastStage = decisionResult.Success || recoverableActivationFailure
? StartupStage.ActivationRedirected
: StartupStage.ActivationFailed;
- lastStageMessage = existingActivation.Message;
- if (existingActivation.Accepted)
+ lastStageMessage = decisionResult.Message;
+ if (decisionResult.Success || recoverableActivationFailure)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
}
- else if (recoverableActivationFailure)
- {
- _startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
- }
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
}
- PublishCoordinatorStatus(
- hostProcessAliveOverride: true,
- completed: true,
- succeeded: existingActivation.Accepted || recoverableActivationFailure);
+ PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: decisionResult.Success);
windowsClosingByCoordinator = true;
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
- success: existingActivation.Accepted || recoverableActivationFailure,
+ success: decisionResult.Success,
stage: "launch",
- code: existingActivation.Accepted
- ? "existing_host_activated"
- : recoverableActivationFailure
- ? "existing_host_startup_pending"
- : "existing_host_activation_failed",
- message: recoverableActivationFailure
- ? "Existing desktop process is still starting; Launcher will not start another process."
- : existingActivation.Message,
+ code: decisionResult.Code,
+ message: decisionResult.Message,
details: MergeDetails(
launcherContextDetails,
new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["publicIpcConnected"] = "true",
- ["existingHostPid"] = existingActivation.Status.ProcessId.ToString(),
- ["existingShellState"] = existingActivation.Status.ShellState,
- ["existingTrayState"] = existingActivation.Status.Tray.State,
- ["existingTaskbarUsable"] = existingActivation.Status.Taskbar.IsUsable.ToString()
+ ["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
+ ["existingHostPid"] = shellStatus?.ProcessId.ToString() ?? string.Empty,
+ ["existingShellState"] = shellStatus?.ShellState ?? string.Empty,
+ ["existingTrayState"] = shellStatus?.Tray.State ?? string.Empty,
+ ["existingTaskbarUsable"] = shellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
+ ["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
}));
}
}
@@ -492,7 +489,7 @@ internal sealed class LauncherFlowCoordinator
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(1200)).ConfigureAwait(false);
if (!connected)
{
- Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
+ Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry.");
}
else
{
@@ -557,30 +554,7 @@ internal sealed class LauncherFlowCoordinator
recoveryActivationAttempted: true));
}
- var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
- if (retryOutcome is not null)
- {
- windowsClosingByCoordinator = true;
- if (retryOutcome.Success)
- {
- _startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
- PublishCoordinatorStatus(
- hostProcessAliveOverride: !launchOutcome.Process.HasExited,
- completed: true,
- succeeded: true);
- }
- else
- {
- _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
- PublishCoordinatorStatus(
- hostProcessAliveOverride: !launchOutcome.Process.HasExited,
- completed: true,
- succeeded: false);
- }
-
- await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
- return WithAdditionalDetails(retryOutcome, ComposeLaunchDetails(!launchOutcome.Process.HasExited, recoveryActivationAttempted: true));
- }
+ Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt.");
}
if (processExitTask.IsCompleted)
@@ -589,7 +563,7 @@ internal sealed class LauncherFlowCoordinator
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
windowsClosingByCoordinator = true;
- if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
+ if (IsSuccessfulActivationExitCode(exitCode))
{
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
@@ -608,7 +582,7 @@ internal sealed class LauncherFlowCoordinator
}
if (!activationRetryAttempted &&
- exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
+ IsFailedActivationExitCode(exitCode))
{
activationRetryAttempted = true;
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
@@ -633,30 +607,7 @@ internal sealed class LauncherFlowCoordinator
}));
}
- var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
- if (retryOutcome is not null)
- {
- if (retryOutcome.Success)
- {
- _startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
- PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
- }
- else
- {
- _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
- PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
- }
-
- await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
- return WithAdditionalDetails(
- retryOutcome,
- MergeDetails(
- ComposeLaunchDetails(hostProcessAlive: false, recoveryActivationAttempted: true),
- new Dictionary(StringComparer.OrdinalIgnoreCase)
- {
- ["exitCode"] = exitCode.ToString()
- }));
- }
+ Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host.");
}
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
@@ -665,10 +616,10 @@ internal sealed class LauncherFlowCoordinator
return BuildResult(
success: false,
stage: "launch",
- code: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
+ code: IsFailedActivationExitCode(exitCode)
? "activation_failed"
: "host_exited_early",
- message: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
+ message: IsFailedActivationExitCode(exitCode)
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
details: MergeDetails(
@@ -909,54 +860,6 @@ internal sealed class LauncherFlowCoordinator
}
}
- private async Task RetryActivationAfterEarlyFailureAsync()
- {
- Logger.Warn("Attempting one explicit activation retry after host early failure.");
- var retryOutcome = await LaunchHostWithIpcAsync(forceDirectMode: true, retryTag: "explicit-activation-retry").ConfigureAwait(false);
- if (!retryOutcome.Result.Success)
- {
- return retryOutcome.Result;
- }
-
- if (retryOutcome.ImmediateResult is not null)
- {
- return retryOutcome.ImmediateResult;
- }
-
- if (retryOutcome.Process is not null)
- {
- var retryExitTask = retryOutcome.Process.WaitForExitAsync();
- var completed = await Task.WhenAny(retryExitTask, Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
-
- if (completed != retryExitTask)
- {
- return BuildResult(
- success: true,
- stage: "launch",
- code: "activation_retry_started",
- message: "Activation retry started the host successfully.",
- details: retryOutcome.Details);
- }
-
- if (retryOutcome.Process.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
- {
- return BuildResult(
- success: true,
- stage: "launch",
- code: "activation_redirected",
- message: "Activation retry redirected to the existing desktop instance.",
- details: retryOutcome.Details);
- }
- }
-
- return BuildResult(
- success: false,
- stage: "launch",
- code: "activation_failed",
- message: "Activation retry failed to make the desktop visible.",
- details: retryOutcome.Details);
- }
-
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
try
@@ -1087,7 +990,7 @@ internal sealed class LauncherFlowCoordinator
previousAttempt is null ? null : finalAttempt,
!finalAttempt.ProcessCreated
? "start"
- : finalAttempt.ExitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
+ : finalAttempt.ExitCode is int finalExitCode && IsFailedActivationExitCode(finalExitCode)
? "activation"
: "early-exit");
@@ -1101,7 +1004,7 @@ internal sealed class LauncherFlowCoordinator
details));
}
- if (finalAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
+ if (finalAttempt.ExitCode is not null && IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value))
{
return HostLaunchOutcome.FromImmediateResult(BuildResult(
true,
@@ -1111,7 +1014,7 @@ internal sealed class LauncherFlowCoordinator
details));
}
- if (finalAttempt.ExitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
+ if (finalAttempt.ExitCode is not null && IsFailedActivationExitCode(finalAttempt.ExitCode.Value))
{
return HostLaunchOutcome.FromResult(BuildResult(
false,
@@ -1469,12 +1372,12 @@ internal sealed class LauncherFlowCoordinator
}
catch (Exception ex)
{
- Logger.Warn($"Public IPC connect failed: {ex.Message}");
+ Logger.Info($"Public IPC is not ready yet: {ex.Message}");
return false;
}
}
- private static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
+ internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
{
if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase))
{
@@ -1489,6 +1392,169 @@ internal sealed class LauncherFlowCoordinator
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
}
+ private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior()
+ {
+ try
+ {
+ var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot());
+ return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+ }
+
+ internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status)
+ {
+ return status is { PublicIpcReady: true, ProcessId: > 0 };
+ }
+
+ private static async Task TryGetExistingHostStatusAsync(
+ LanMountainDesktopIpcClient ipcClient,
+ TimeSpan timeout)
+ {
+ try
+ {
+ var connected = ipcClient.IsConnected ||
+ await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
+ if (!connected)
+ {
+ return null;
+ }
+
+ var shellProxy = ipcClient.CreateProxy();
+ return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Info($"Existing host status probe did not complete: {ex.Message}");
+ return null;
+ }
+ }
+
+ private static async Task ApplyExistingHostBehaviorAsync(
+ LanMountainDesktopIpcClient ipcClient,
+ MultiInstanceLaunchBehavior behavior,
+ PublicShellStatus status)
+ {
+ try
+ {
+ var shellProxy = ipcClient.CreateProxy();
+ return behavior switch
+ {
+ MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: false,
+ successCode: "existing_host_activated",
+ successMessage: "Launcher activated the existing desktop instance.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false),
+
+ MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: true,
+ successCode: "existing_host_activated_with_notice",
+ successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false),
+
+ MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
+ shellProxy,
+ status).ConfigureAwait(false),
+
+ MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
+
+ _ => await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: true,
+ successCode: "existing_host_activated_with_notice",
+ successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false)
+ };
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
+ return new ExistingHostBehaviorResult(
+ false,
+ "multi_instance_behavior_failed",
+ $"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
+ null);
+ }
+ }
+
+ private static async Task ActivateExistingHostForBehaviorAsync(
+ IPublicShellControlService shellProxy,
+ bool showLauncherNotice,
+ string successCode,
+ string successMessage,
+ string failureCode)
+ {
+ var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
+ var success = activation.Accepted || IsRecoverableActivationFailure(activation);
+ if (showLauncherNotice && success)
+ {
+ var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
+ if (promptResult == MultiInstancePromptResult.OpenDesktop)
+ {
+ activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
+ }
+ }
+
+ return new ExistingHostBehaviorResult(
+ success,
+ activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
+ activation.Accepted ? successMessage : activation.Message,
+ activation);
+ }
+
+ private static async Task RestartExistingHostAsync(
+ IPublicShellControlService shellProxy)
+ {
+ var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
+ return new ExistingHostBehaviorResult(
+ accepted,
+ accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
+ accepted
+ ? "Launcher requested the existing desktop instance to restart."
+ : "Launcher could not request restart from the existing desktop instance.",
+ null);
+ }
+
+ private static async Task ShowPromptOnlyExistingHostAsync(
+ IPublicShellControlService shellProxy,
+ PublicShellStatus status)
+ {
+ var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
+
+ if (promptResult == MultiInstancePromptResult.OpenDesktop)
+ {
+ return await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: false,
+ successCode: "existing_host_activated_from_prompt",
+ successMessage: "Launcher activated the existing desktop instance from the prompt.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false);
+ }
+
+ return new ExistingHostBehaviorResult(
+ true,
+ "existing_host_prompt_only",
+ "Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
+ null);
+ }
+
+ private static async Task ShowMultiInstancePromptAsync(PublicShellStatus status)
+ {
+ return await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var prompt = new MultiInstancePromptWindow();
+ prompt.SetDetails(status.ProcessId, status.ShellState);
+ prompt.Show();
+ return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
+ });
+ }
+
private static async Task TryActivateExistingHostWithStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
@@ -1507,7 +1573,7 @@ internal sealed class LauncherFlowCoordinator
}
catch (Exception ex)
{
- Logger.Warn($"Existing host activation probe failed: {ex.Message}");
+ Logger.Info($"Existing host activation probe did not complete: {ex.Message}");
return null;
}
}
@@ -1541,7 +1607,7 @@ internal sealed class LauncherFlowCoordinator
: null;
}
- private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
+ internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
{
if (activation.Accepted)
{
@@ -1560,6 +1626,12 @@ internal sealed class LauncherFlowCoordinator
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
}
+ internal static bool IsSuccessfulActivationExitCode(int exitCode) =>
+ exitCode == HostExitCodes.SecondaryActivationSucceeded;
+
+ internal static bool IsFailedActivationExitCode(int exitCode) =>
+ exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired;
+
private static async Task TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{
@@ -1759,6 +1831,12 @@ internal sealed class LauncherFlowCoordinator
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
}
+ private sealed record ExistingHostBehaviorResult(
+ bool Success,
+ string Code,
+ string Message,
+ PublicShellActivationResult? ActivationResult);
+
private sealed record HostLaunchOutcome(
LauncherResult Result,
Process? Process,
diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
index 3bfb78b..84482ca 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
@@ -3,16 +3,20 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
+ xmlns:ui="using:FluentAvalonia.UI.Controls"
+ xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow"
Title="LanMountain Desktop"
- Width="560"
- Height="320"
+ Width="760"
+ Height="460"
+ MinWidth="640"
+ MinHeight="420"
CanResize="False"
WindowStartupLocation="CenterScreen"
- Background="#111318"
- TransparencyLevelHint="None"
+ Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
+ TransparencyLevelHint="Mica, AcrylicBlur, None"
Icon="/Assets/logo.ico">
@@ -20,79 +24,128 @@
-
+
+ Spacing="8">
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+ Padding="18,14"
+ Background="{DynamicResource LayerOnMicaBaseAltFillColorDefaultBrush}">
+
+
+
-
+
+
-
+
+
-
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
index a87271c..b18b456 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
@@ -1,8 +1,10 @@
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
+using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
@@ -33,9 +35,21 @@ public partial class ErrorWindow : Window
public void SetErrorMessage(string message)
{
+ var normalizedMessage = string.IsNullOrWhiteSpace(message)
+ ? "LanMountain Desktop did not reach the expected startup state."
+ : message.Trim();
+ var firstLine = normalizedMessage
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault() ?? normalizedMessage;
+
if (this.FindControl("ErrorMessageText") is { } errorText)
{
- errorText.Text = message;
+ errorText.Text = firstLine;
+ }
+
+ if (this.FindControl("ErrorDetailsTextBox") is { } detailsTextBox)
+ {
+ detailsTextBox.Text = normalizedMessage;
}
}
@@ -120,6 +134,11 @@ public partial class ErrorWindow : Window
{
openLogButton.Click += OnOpenLogClick;
}
+
+ if (this.FindControl