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"> - - + + + + + + + + + + - - - + - + + + + + + + + + + + diff --git a/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs new file mode 100644 index 0000000..65c1d44 --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs @@ -0,0 +1,76 @@ +using Avalonia.Controls; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; + +namespace LanMountainDesktop.Launcher.Views; + +public partial class MultiInstancePromptWindow : Window +{ + private readonly TaskCompletionSource _completionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private string _details = "LanMountain Desktop is already running."; + + public MultiInstancePromptWindow() + { + AvaloniaXamlLoader.Load(this); + Loaded += OnLoaded; + Closed += (_, _) => _completionSource.TrySetResult(MultiInstancePromptResult.Close); + } + + public Task WaitForChoiceAsync() => _completionSource.Task; + + public void SetDetails(int processId, string shellState) + { + _details = $"Existing host PID: {processId}\nShell state: {shellState}\nNo second Host process was created."; + + if (this.FindControl("DetailsText") is { } detailsText) + { + detailsText.Text = _details; + } + } + + private void OnLoaded(object? sender, RoutedEventArgs e) + { + if (this.FindControl + + + +
+
+
+
+
+ 7 + / + 24 +
+
+ 周一 +
+
6节课
+
+
+
+

2×4 标准尺寸

+
+ +
+
+
+
+ 7 + / + 24 +
+
+ 周一 +
+
6节课
+
+
+
+

4×4 大尺寸

+
+
+ + + +