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("CopyDetailsButton") is { } copyDetailsButton) + { + copyDetailsButton.Click += OnCopyDetailsClick; + } } private void ApplyActionLayout( @@ -138,9 +157,9 @@ public partial class ErrorWindow : Window titleText.Text = title; } - if (this.FindControl("SuggestionText") is { } suggestionText) + if (this.FindControl("SuggestionInfoBar") is { } suggestionInfoBar) { - suggestionText.Text = suggestion; + suggestionInfoBar.Message = suggestion; } if (this.FindControl("PrimaryActionButton") is { } primaryButton) @@ -243,6 +262,28 @@ public partial class ErrorWindow : Window await Task.CompletedTask; } + private async void OnCopyDetailsClick(object? sender, RoutedEventArgs e) + { + try + { + var details = this.FindControl("ErrorDetailsTextBox")?.Text; + if (string.IsNullOrWhiteSpace(details)) + { + details = this.FindControl("ErrorMessageText")?.Text; + } + + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard is not null && !string.IsNullOrWhiteSpace(details)) + { + await clipboard.SetTextAsync(details); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[ErrorWindow] Failed to copy diagnostics: {ex}"); + } + } + private void ScanDevPaths() { var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; diff --git a/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml b/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml new file mode 100644 index 0000000..99d95f6 --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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("CloseButton") is { } closeButton) + { + closeButton.Click += (_, _) => Complete(MultiInstancePromptResult.Close); + } + + if (this.FindControl("OpenDesktopButton") is { } openDesktopButton) + { + openDesktopButton.Click += (_, _) => Complete(MultiInstancePromptResult.OpenDesktop); + } + + if (this.FindControl("CopyDetailsButton") is { } copyDetailsButton) + { + copyDetailsButton.Click += OnCopyDetailsClick; + } + } + + private void Complete(MultiInstancePromptResult result) + { + _completionSource.TrySetResult(result); + Close(); + } + + private async void OnCopyDetailsClick(object? sender, RoutedEventArgs e) + { + try + { + if (TopLevel.GetTopLevel(this)?.Clipboard is IClipboard clipboard) + { + await clipboard.SetTextAsync(_details); + } + } + catch + { + } + } +} + +public enum MultiInstancePromptResult +{ + Close, + OpenDesktop +} diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/HostExitCodes.cs b/LanMountainDesktop.Shared.Contracts/Launcher/HostExitCodes.cs index f0f78fe..07b8751 100644 --- a/LanMountainDesktop.Shared.Contracts/Launcher/HostExitCodes.cs +++ b/LanMountainDesktop.Shared.Contracts/Launcher/HostExitCodes.cs @@ -7,12 +7,12 @@ public static class HostExitCodes { public const int Success = 0; - // Secondary instance activated the existing primary instance successfully. + // Legacy host-side activation result retained for old builds and launcher compatibility. public const int SecondaryActivationSucceeded = 12; - // Secondary instance failed to activate the existing primary instance. + // Legacy host-side activation failure retained for old builds and launcher compatibility. public const int SecondaryActivationFailed = 13; - // Restart relaunch couldn't acquire the single-instance lock in time. + // Legacy restart lock failure retained for old builds and launcher compatibility. public const int RestartLockNotAcquired = 14; } diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs index aea0b84..503c971 100644 --- a/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs +++ b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs @@ -28,8 +28,6 @@ public record StartupProgressMessage public static class LauncherIpcConstants { - public const string PipeName = "LanMountainDesktop_Launcher"; - public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID"; public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT"; diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/MultiInstanceLaunchBehavior.cs b/LanMountainDesktop.Shared.Contracts/Launcher/MultiInstanceLaunchBehavior.cs new file mode 100644 index 0000000..dd58312 --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Launcher/MultiInstanceLaunchBehavior.cs @@ -0,0 +1,9 @@ +namespace LanMountainDesktop.Shared.Contracts.Launcher; + +public enum MultiInstanceLaunchBehavior +{ + RestartApp, + OpenDesktopSilently, + PromptOnly, + NotifyAndOpenDesktop +} diff --git a/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs b/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs index dd9e7fc..a16a20e 100644 --- a/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs +++ b/LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs @@ -1,4 +1,5 @@ using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Shared.Contracts.Launcher; using Xunit; namespace LanMountainDesktop.Tests; @@ -88,4 +89,54 @@ public sealed class HostAppSettingsOobeMergerTests Directory.Delete(dir, recursive: true); } } + + [Theory] + [InlineData("RestartApp", MultiInstanceLaunchBehavior.RestartApp)] + [InlineData("OpenDesktopSilently", MultiInstanceLaunchBehavior.OpenDesktopSilently)] + [InlineData("PromptOnly", MultiInstanceLaunchBehavior.PromptOnly)] + [InlineData("NotifyAndOpenDesktop", MultiInstanceLaunchBehavior.NotifyAndOpenDesktop)] + public void LoadMultiInstanceLaunchBehavior_ReadsStringValues( + string value, + MultiInstanceLaunchBehavior expected) + { + var dir = Path.Combine(Path.GetTempPath(), "LMD.MultiInstanceSettings", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "settings.json"); + File.WriteAllText(path, $$""" + { + "MultiInstanceLaunchBehavior": "{{value}}" + } + """); + + try + { + Assert.Equal(expected, HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(path)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Theory] + [InlineData("{}")] + [InlineData("{ \"MultiInstanceLaunchBehavior\": \"Unknown\" }")] + public void LoadMultiInstanceLaunchBehavior_FallsBackToNotifyAndOpenDesktop(string json) + { + var dir = Path.Combine(Path.GetTempPath(), "LMD.MultiInstanceSettings", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "settings.json"); + File.WriteAllText(path, json); + + try + { + Assert.Equal( + MultiInstanceLaunchBehavior.NotifyAndOpenDesktop, + HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(path)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } } diff --git a/LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs b/LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs new file mode 100644 index 0000000..dd3df24 --- /dev/null +++ b/LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs @@ -0,0 +1,123 @@ +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Models; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class LauncherMultiInstancePolicyTests +{ + [Fact] + public void AppSettingsSnapshot_DefaultsToNotifyAndOpenDesktop() + { + Assert.Equal( + MultiInstanceLaunchBehavior.NotifyAndOpenDesktop, + new AppSettingsSnapshot().MultiInstanceLaunchBehavior); + } + + [Fact] + public void ShouldProbeExistingHostBeforeLaunch_ReturnsTrue_ForNormalLaunch() + { + var context = CommandContext.FromArgs(["launch"]); + + Assert.True(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context)); + } + + [Fact] + public void ShouldProbeExistingHostBeforeLaunch_ReturnsFalse_ForRestartLaunch() + { + var context = CommandContext.FromArgs([ + "launch", + $"--{LauncherIpcConstants.LaunchSourceOptionName}=restart" + ]); + + Assert.False(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context)); + } + + [Fact] + public void ActivationExitCodes_AreClassifiedSeparatelyFromEarlyHostExit() + { + Assert.True(LauncherFlowCoordinator.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded)); + Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed)); + Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired)); + Assert.False(LauncherFlowCoordinator.IsFailedActivationExitCode(1)); + } + + [Fact] + public void IsRecoverableActivationFailure_ReturnsTrue_WhenPublicIpcIsReadyButShellIsPending() + { + var activation = new PublicShellActivationResult( + false, + "shell_not_ready", + "Desktop shell is still initializing.", + CreateShellStatus( + publicIpcReady: true, + mainWindowOpened: false, + desktopVisible: false)); + + Assert.True(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation)); + } + + [Fact] + public void IsRecoverableActivationFailure_ReturnsFalse_WhenShutdownIsInProgress() + { + var activation = new PublicShellActivationResult( + false, + "shutdown_in_progress", + "Desktop is shutting down.", + CreateShellStatus( + publicIpcReady: true, + mainWindowOpened: false, + desktopVisible: false)); + + Assert.False(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation)); + } + + [Fact] + public void IsExistingHostReadyForLauncherDecision_RequiresPublicIpcReady() + { + Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(null)); + Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus( + publicIpcReady: false, + mainWindowOpened: true, + desktopVisible: true))); + Assert.True(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus( + publicIpcReady: true, + mainWindowOpened: true, + desktopVisible: true))); + } + + + private static PublicShellStatus CreateShellStatus( + bool publicIpcReady, + bool mainWindowOpened, + bool desktopVisible) + { + return new PublicShellStatus( + ProcessId: Environment.ProcessId, + StartedAtUtc: DateTimeOffset.UtcNow, + LaunchSource: "normal", + ShellState: mainWindowOpened ? "opened" : "initializing", + MainWindowCreated: mainWindowOpened, + MainWindowVisible: desktopVisible, + MainWindowOpened: mainWindowOpened, + DesktopVisible: desktopVisible, + PublicIpcReady: publicIpcReady, + Tray: new PublicTrayStatus( + State: "Unavailable", + IsReady: false, + HasIcon: false, + HasMenu: false, + IsVisible: false, + ConsecutiveRecoveryFailures: 0), + Taskbar: new PublicTaskbarStatus( + RequestedBySettings: false, + MainWindowExists: mainWindowOpened, + MainWindowShowInTaskbar: mainWindowOpened, + MainWindowVisible: desktopVisible, + MainWindowMinimized: false, + IsUsable: mainWindowOpened)); + } +} diff --git a/LanMountainDesktop.Tests/NotificationListenerServiceTests.cs b/LanMountainDesktop.Tests/NotificationListenerServiceTests.cs new file mode 100644 index 0000000..c0e3af7 --- /dev/null +++ b/LanMountainDesktop.Tests/NotificationListenerServiceTests.cs @@ -0,0 +1,106 @@ +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class NotificationListenerServiceTests +{ + [Fact] + public void AddNotification_DeduplicatesByPlatformAndSourceId() + { + var settings = new FakeSettingsService(); + var service = new NotificationListenerService(settings); + + service.AddNotification(new NotificationItem + { + Platform = "Windows", + SourceNotificationId = "42", + AppId = "mail", + AppName = "Mail", + Title = "First" + }); + service.AddNotification(new NotificationItem + { + Platform = "Windows", + SourceNotificationId = "42", + AppId = "mail", + AppName = "Mail", + Title = "Updated" + }); + + var notification = Assert.Single(service.GetNotifications()); + Assert.Equal("Updated", notification.Title); + } + + [Fact] + public void AddNotification_RespectsBlockedApps() + { + var settings = new FakeSettingsService(); + settings.Snapshot.NotificationBoxBlockedApps.Add("blocked-app"); + var service = new NotificationListenerService(settings); + + service.AddNotification(new NotificationItem + { + AppId = "blocked-app", + AppName = "Blocked", + Title = "Hidden" + }); + + Assert.Empty(service.GetNotifications()); + } + + [Fact] + public void AddNotification_TrimsToMaxStoredCount() + { + var settings = new FakeSettingsService(); + settings.Snapshot.NotificationBoxMaxStoredCount = 2; + var service = new NotificationListenerService(settings); + + service.AddNotification(new NotificationItem { AppId = "a", AppName = "A", Title = "1" }); + service.AddNotification(new NotificationItem { AppId = "b", AppName = "B", Title = "2" }); + service.AddNotification(new NotificationItem { AppId = "c", AppName = "C", Title = "3" }); + + var notifications = service.GetNotifications(); + Assert.Equal(2, notifications.Count); + Assert.DoesNotContain(notifications, n => n.Title == "1"); + } + + private sealed class FakeSettingsService : ISettingsService + { + public AppSettingsSnapshot Snapshot { get; } = new(); + + public event EventHandler? Changed; + + public T LoadSnapshot(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new() + => typeof(T) == typeof(AppSettingsSnapshot) + ? (T)(object)Snapshot + : new T(); + + public void SaveSnapshot(SettingsScope scope, T snapshot, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection? changedKeys = null) + { + } + + public T LoadSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new() + => new(); + + public void SaveSection(SettingsScope scope, string subjectId, string sectionId, T section, string? placementId = null, IReadOnlyCollection? changedKeys = null) + { + } + + public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) + { + } + + public T? GetValue(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null) + => default; + + public void SetValue(SettingsScope scope, string key, T value, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection? changedKeys = null) + { + } + + public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId) + => throw new NotSupportedException(); + } +} diff --git a/LanMountainDesktop.Tests/SingleInstanceServiceTests.cs b/LanMountainDesktop.Tests/SingleInstanceServiceTests.cs deleted file mode 100644 index 305b727..0000000 --- a/LanMountainDesktop.Tests/SingleInstanceServiceTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using LanMountainDesktop.Services; -using Xunit; - -namespace LanMountainDesktop.Tests; - -public sealed class SingleInstanceServiceTests -{ - [Fact] - public async Task TryNotifyPrimaryInstance_ReturnsTrue_WhenPrimaryAcknowledges() - { - var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}"; - var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}"; - - using var primary = CreateService(mutexName, pipeName); - using var secondary = CreateSecondaryService(mutexName, pipeName); - Assert.True(primary.IsPrimaryInstance); - MarkAsSecondaryForTest(secondary); - - var activated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - primary.StartActivationListener(() => activated.TrySetResult()); - - var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason); - - Assert.True(acknowledged); - Assert.Null(failureReason); - - var completed = await Task.WhenAny(activated.Task, Task.Delay(TimeSpan.FromSeconds(2))); - Assert.Same(activated.Task, completed); - } - - [Fact] - public void TryNotifyPrimaryInstance_ReturnsFalse_WhenListenerIsNotRunning() - { - var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}"; - var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}"; - - using var primary = CreateService(mutexName, pipeName); - using var secondary = CreateSecondaryService(mutexName, pipeName); - Assert.True(primary.IsPrimaryInstance); - MarkAsSecondaryForTest(secondary); - - var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromMilliseconds(300), out var failureReason); - - Assert.False(acknowledged); - Assert.False(string.IsNullOrWhiteSpace(failureReason)); - } - - private static SingleInstanceService CreateService(string mutexName, string pipeName) - { - var ctor = typeof(SingleInstanceService).GetConstructor( - BindingFlags.Instance | BindingFlags.NonPublic, - binder: null, - [typeof(string), typeof(string)], - modifiers: null); - - Assert.NotNull(ctor); - return (SingleInstanceService)ctor!.Invoke([mutexName, pipeName]); - } - - private static SingleInstanceService CreateSecondaryService(string mutexName, string pipeName) - { - SingleInstanceService? created = null; - Exception? creationError = null; - var thread = new Thread(() => - { - try - { - created = CreateService(mutexName, pipeName); - } - catch (Exception ex) - { - creationError = ex; - } - }); - - thread.IsBackground = true; - thread.Start(); - thread.Join(); - - if (creationError is not null) - { - throw new InvalidOperationException("Failed to create secondary SingleInstanceService.", creationError); - } - - Assert.NotNull(created); - return created!; - } - - private static void MarkAsSecondaryForTest(SingleInstanceService service) - { - var ownsMutexField = typeof(SingleInstanceService).GetField( - "_ownsMutex", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(ownsMutexField); - ownsMutexField!.SetValue(service, false); - Assert.False(service.IsPrimaryInstance); - } -} diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index a59d19a..3a45522 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -20,7 +20,6 @@ using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.ExternalIpc; -using LanMountainDesktop.Services.Launcher; using LanMountainDesktop.Services.Loading; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Update; @@ -83,7 +82,6 @@ public partial class App : Application private PublicIpcHostService? _publicIpcHostService; private LoadingStateManager? _loadingStateManager; private LoadingStateReporter? _loadingStateReporter; - private bool _singleInstanceReleased; private int _forcedExitScheduled; private volatile bool _desktopShellInitializationStarted; private bool _mainWindowOpened; @@ -91,7 +89,6 @@ public partial class App : Application private readonly object _launcherProgressLock = new(); private readonly List _pendingLauncherProgressMessages = []; - internal static SingleInstanceService? CurrentSingleInstanceService { get; set; } internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle => (Current as App)?._hostApplicationLifecycle; internal static INotificationService? CurrentNotificationService => @@ -213,7 +210,6 @@ public partial class App : Application LinuxDesktopEntryInstaller.EnsureInstalled(); InitializePublicIpc(); - CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow); _ = InitializeLauncherIpcAsync(); DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell); @@ -368,7 +364,7 @@ public partial class App : Application CreateAndAssignMainWindow(desktop, "FrameworkInitialization"); }, OnDesktopLifetimeExit, - () => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow), + static () => { }, StartWeatherLocationRefreshIfNeeded); _desktopShellHost.Initialize(this); } @@ -377,7 +373,6 @@ public partial class App : Application { AppLogger.Info("App", "Desktop lifetime exit triggered."); PerformExitCleanup(); - ReleaseSingleInstanceAfterExit("DesktopLifetimeExit"); ScheduleForcedProcessTermination("DesktopLifetimeExit"); } @@ -396,7 +391,7 @@ public partial class App : Application return; } - RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu"); + RestoreOrCreateMainWindow("TrayMenu"); } private void OnTrayRestartClick(object? sender, EventArgs e) @@ -723,7 +718,7 @@ public partial class App : Application if (_desktopShellState == DesktopShellState.TrayOnly) { - RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed"); + RestoreOrCreateMainWindow("TrayAvailabilityFailed"); return; } @@ -734,7 +729,7 @@ public partial class App : Application !taskbarUsable && (_desktopTrayService?.ConsecutiveRecoveryFailures ?? 0) >= 3) { - RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityRepeatedFailure"); + RestoreOrCreateMainWindow("TrayAvailabilityRepeatedFailure"); } } @@ -862,39 +857,7 @@ public partial class App : Application Resources["AppFontFamily"] = fontFamily; } - internal void ActivateMainWindow() - { - AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}."); - - if (!_desktopShellInitializationStarted && _mainWindow is null) - { - AppLogger.Info("SingleInstance", "Activation acknowledged while desktop shell is still initializing."); - return; - } - - try - { - var restored = Dispatcher.UIThread.CheckAccess() - ? RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance") - : Dispatcher.UIThread.InvokeAsync( - () => RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance"), - DispatcherPriority.Send).GetAwaiter().GetResult(); - - if (!restored) - { - AppLogger.Warn("SingleInstance", "Activation callback could not restore the main window yet."); - return; - } - - AppLogger.Info("SingleInstance", "Activation callback completed successfully."); - } - catch (Exception ex) - { - AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex); - } - } - - private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source) + private void RestoreOrCreateMainWindow(string source) { if (IsShutdownInProgress) { @@ -904,11 +867,11 @@ public partial class App : Application Dispatcher.UIThread.Post(() => { - _ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source); + _ = RestoreOrCreateMainWindowCore(source); }, DispatcherPriority.Send); } - private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source) + private bool RestoreOrCreateMainWindowCore(string source) { if (IsShutdownInProgress) { @@ -966,12 +929,7 @@ public partial class App : Application SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}"); AppLogger.Info( "DesktopShell", - $"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'."); - - if (showSingleInstanceNotice) - { - mainWindow.ShowSingleInstanceNotice(); - } + $"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; WindowState='{mainWindow.WindowState}'."); return true; } @@ -989,7 +947,7 @@ public partial class App : Application _transparentOverlayWindow = new TransparentOverlayWindow(); _transparentOverlayWindow.RestoreMainWindowRequested += (s, e) => { - RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay"); + RestoreOrCreateMainWindow("TransparentOverlay"); }; _transparentOverlayWindow.ExitEditRequested += (s, e) => { @@ -1044,7 +1002,6 @@ public partial class App : Application ScheduleForcedProcessTermination($"ShutdownRequest:{source}"); StopShellRecoveryWatchdog(); PerformExitCleanup(); - ReleaseSingleInstanceAfterExit($"ShutdownRequest:{source}"); try { @@ -1195,33 +1152,6 @@ public partial class App : Application _appearanceThemeService.ApplyThemeResources(Resources); } - private void ReleaseSingleInstanceAfterExit(string source) - { - if (_singleInstanceReleased) - { - return; - } - - _singleInstanceReleased = true; - var singleInstance = CurrentSingleInstanceService; - CurrentSingleInstanceService = null; - if (singleInstance is null) - { - AppLogger.Info("SingleInstance", $"No single-instance handle to release. Source='{source}'."); - return; - } - - try - { - singleInstance.Dispose(); - AppLogger.Info("SingleInstance", $"Released single-instance handle. Source='{source}'."); - } - catch (Exception ex) - { - AppLogger.Warn("SingleInstance", $"Failed to release single-instance handle. Source='{source}'.", ex); - } - } - private void ScheduleForcedProcessTermination(string source) { if (Interlocked.Exchange(ref _forcedExitScheduled, 1) != 0) @@ -1809,7 +1739,7 @@ public partial class App : Application GetPublicShellStatus()); } - var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source); + var restored = RestoreOrCreateMainWindowCore(source); var status = GetPublicShellStatus(); if (restored) { diff --git a/LanMountainDesktop/Assets/Weather/ATTRIBUTION.md b/LanMountainDesktop/Assets/Weather/ATTRIBUTION.md deleted file mode 100644 index c7ab638..0000000 --- a/LanMountainDesktop/Assets/Weather/ATTRIBUTION.md +++ /dev/null @@ -1,40 +0,0 @@ -# 天气背景资源署名 - -## 中文 - -本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用: - -- License: https://www.pexels.com/license/ - -### 原始来源 - -- `clear_sky.jpg` - - https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/ -- `rain.jpg` - - https://www.pexels.com/photo/rain-on-window-with-bokeh-lights-35075853/ -- `snow.jpg` - - https://www.pexels.com/photo/mountain-covered-with-snow-209955/ -- `storm.jpg` - - https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/ - -### 派生资源 - -以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉: - -- `clear_day.jpg` -- `clear_night.jpg` -- `cloudy_day.jpg` -- `cloudy_night.jpg` -- `rain_light.jpg` -- `rain_heavy.jpg` -- `storm_dark.jpg` -- `fog_haze.jpg` -- `snow_soft.jpg` - -## English - -The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License: - -- License: https://www.pexels.com/license/ - -Derived variants in this repository are adjusted from the listed base assets for widget presentation. diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md b/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md deleted file mode 100644 index 1422532..0000000 --- a/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md +++ /dev/null @@ -1,37 +0,0 @@ -# HyperOS3 天气资源署名 - -## 中文 - -本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。 - -### 提取来源 - -- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk` -- Package: `com.miui.weather2` -- Extraction date: `2026-03-03` - -### 用途说明 - -- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。 -- 使用时应遵守小米相关许可与使用条款。 - -### 额外派生资源 - -以下文件为项目内基于上述视觉方向制作的派生素材: - -- `Icons/icon_hero_sun_soft.png` -- `Icons/icon_hero_moon_soft.png` -- `Icons/icon_mini_partly_cloudy_day_soft.png` -- `Icons/icon_mini_partly_cloudy_night_soft.png` -- `Icons/icon_mini_cloudy_soft.png` -- `Icons/icon_mini_rain_light_soft.png` -- `Icons/icon_mini_rain_heavy_soft.png` -- `Icons/icon_mini_storm_soft.png` -- `Icons/icon_mini_snow_soft.png` -- `Icons/icon_mini_fog_soft.png` - -## English - -The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction. - -Use these resources only in accordance with Xiaomi's applicable license and usage terms. diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp deleted file mode 100644 index 228d2bd..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp deleted file mode 100644 index f34ef53..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png deleted file mode 100644 index a17b056..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png deleted file mode 100644 index 0b95e2d..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png deleted file mode 100644 index 9d752f9..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png deleted file mode 100644 index 764884d..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png deleted file mode 100644 index 3ee7efe..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png deleted file mode 100644 index 3ba9cbe..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png deleted file mode 100644 index 2a9a4d0..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png deleted file mode 100644 index 08d5e68..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png deleted file mode 100644 index e8ae667..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png deleted file mode 100644 index 3711dc9..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp deleted file mode 100644 index 5e1df1f..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp deleted file mode 100644 index 65e334f..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp deleted file mode 100644 index 38aa31e..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp deleted file mode 100644 index 6f233f6..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp deleted file mode 100644 index e373622..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp deleted file mode 100644 index 50d69bd..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp deleted file mode 100644 index b17c064..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp deleted file mode 100644 index 96d98c8..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp deleted file mode 100644 index 4602164..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp deleted file mode 100644 index 2c55d40..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_day.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_day.png deleted file mode 100644 index 8af2167..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_day.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png deleted file mode 100644 index 80322ab..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_fog.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_fog.png deleted file mode 100644 index f1aa1e7..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_fog.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_haze.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_haze.png deleted file mode 100644 index e24725a..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_haze.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_rain_drop.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_rain_drop.png deleted file mode 100644 index bcb304b..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_rain_drop.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png deleted file mode 100644 index 3593e2a..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_bottom.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_bottom.png deleted file mode 100644 index 7275992..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_bottom.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png deleted file mode 100644 index 3497949..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_left.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_left.png deleted file mode 100644 index 3728f1a..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_left.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_right.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_right.png deleted file mode 100644 index cdc69ed..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_right.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_top.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_top.png deleted file mode 100644 index e9488ae..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_top.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png deleted file mode 100644 index 984abeb..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png deleted file mode 100644 index 8ffaeea..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_ring.png b/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_ring.png deleted file mode 100644 index b57761e..0000000 Binary files a/LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_ring.png and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/clear_day.jpg b/LanMountainDesktop/Assets/Weather/clear_day.jpg deleted file mode 100644 index 74d496a..0000000 Binary files a/LanMountainDesktop/Assets/Weather/clear_day.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/clear_night.jpg b/LanMountainDesktop/Assets/Weather/clear_night.jpg deleted file mode 100644 index 8de0262..0000000 Binary files a/LanMountainDesktop/Assets/Weather/clear_night.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/clear_sky.jpg b/LanMountainDesktop/Assets/Weather/clear_sky.jpg deleted file mode 100644 index 74d496a..0000000 Binary files a/LanMountainDesktop/Assets/Weather/clear_sky.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/cloudy_day.jpg b/LanMountainDesktop/Assets/Weather/cloudy_day.jpg deleted file mode 100644 index cb29d9d..0000000 Binary files a/LanMountainDesktop/Assets/Weather/cloudy_day.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/cloudy_night.jpg b/LanMountainDesktop/Assets/Weather/cloudy_night.jpg deleted file mode 100644 index 57929b8..0000000 Binary files a/LanMountainDesktop/Assets/Weather/cloudy_night.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/fog_haze.jpg b/LanMountainDesktop/Assets/Weather/fog_haze.jpg deleted file mode 100644 index 9a0c1ea..0000000 Binary files a/LanMountainDesktop/Assets/Weather/fog_haze.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/rain.jpg b/LanMountainDesktop/Assets/Weather/rain.jpg deleted file mode 100644 index e9b2083..0000000 Binary files a/LanMountainDesktop/Assets/Weather/rain.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/rain_heavy.jpg b/LanMountainDesktop/Assets/Weather/rain_heavy.jpg deleted file mode 100644 index 6c71493..0000000 Binary files a/LanMountainDesktop/Assets/Weather/rain_heavy.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/rain_light.jpg b/LanMountainDesktop/Assets/Weather/rain_light.jpg deleted file mode 100644 index 6d57298..0000000 Binary files a/LanMountainDesktop/Assets/Weather/rain_light.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/snow.jpg b/LanMountainDesktop/Assets/Weather/snow.jpg deleted file mode 100644 index 1d70a6b..0000000 Binary files a/LanMountainDesktop/Assets/Weather/snow.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/snow_soft.jpg b/LanMountainDesktop/Assets/Weather/snow_soft.jpg deleted file mode 100644 index f48fd4e..0000000 Binary files a/LanMountainDesktop/Assets/Weather/snow_soft.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/storm.jpg b/LanMountainDesktop/Assets/Weather/storm.jpg deleted file mode 100644 index a4a65dc..0000000 Binary files a/LanMountainDesktop/Assets/Weather/storm.jpg and /dev/null differ diff --git a/LanMountainDesktop/Assets/Weather/storm_dark.jpg b/LanMountainDesktop/Assets/Weather/storm_dark.jpg deleted file mode 100644 index a4a65dc..0000000 Binary files a/LanMountainDesktop/Assets/Weather/storm_dark.jpg and /dev/null differ diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index df2b6b6..3b157ce 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -26,6 +26,7 @@ + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 3a7fc45..deb72ef 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -352,6 +352,12 @@ "settings.general.slide_transition_desc": "Use a slide-in startup transition on supported Windows builds. This option disables fade transition.", "settings.general.show_main_window_taskbar_header": "Show main desktop window in taskbar", "settings.general.show_main_window_taskbar_desc": "Keep the main desktop host window visible in the taskbar. The independent settings window always has its own taskbar entry.", + "settings.general.multi_instance_behavior_header": "When opening the app again", + "settings.general.multi_instance_behavior_desc": "Choose how Launcher handles repeated launches while LanMountain Desktop is already running.", + "settings.general.multi_instance_behavior.restart": "Restart app", + "settings.general.multi_instance_behavior.open_silently": "Open desktop without prompt", + "settings.general.multi_instance_behavior.prompt_only": "Show prompt only", + "settings.general.multi_instance_behavior.notify_and_open": "Notify and open desktop", "settings.data.title": "Data", "settings.data.description": "Review and manage local app storage and cache.", "settings.appearance.title": "Appearance", @@ -560,9 +566,17 @@ "settings.update.description": "Check releases, choose the update channel and download source, and control how updates are installed.", "settings.update.status_card_title": "Update Status", "settings.update.status_card_description": "Check for updates, review release details, and continue with download or installation when a new version is available.", - "settings.update.preferences_header": "Update Preferences", + "settings.update.release_facts_title": "Release Facts", + "settings.update.release_facts_description": "Keep the current version, published release, and update type visible without collapsing the layout while states change.", + "settings.update.progress_title": "Progress", + "settings.update.progress_description": "Watch download, installation, verification, and recovery progress here.", + "settings.update.actions_title": "Actions", + "settings.update.actions_description": "The buttons below stay in place while the update phase changes, so the page does not jump around.", + "settings.update.preferences_title": "Update Preferences", "settings.update.preferences_description": "Choose the release channel, installer download source, installation behavior, and download parallelism.", "settings.update.last_checked_label": "Last Checked", + "settings.update.last_checked_none": "Not checked yet.", + "settings.update.last_checked_format": "Last checked: {0}", "settings.update.source_label": "Download Source", "settings.update.source_github": "GitHub", "settings.update.source_ghproxy": "gh-proxy", @@ -579,15 +593,146 @@ "settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.", "settings.update.download_threads_label": "Download Threads", "settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.", - "settings.update.force_check_label": "Force Check Update", - "settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.", - "settings.update.status_force_checking": "Force checking GitHub releases...", - "settings.update.status_force_no_asset": "Release found but no compatible installer available.", - "settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.", - "settings.update.install_now_button": "Install Now", + "settings.update.type_label": "Update Type", + "settings.update.status_idle": "No update check has been performed yet.", + "settings.update.status_preferences_saved": "Update preferences saved.", + "settings.update.status_check_failed": "Failed to check for updates.", + "settings.update.status_available_summary_format": "Update available: {0} (current: {1})", + "settings.update.status_up_to_date_format": "You are up to date ({0}).", + "settings.update.status_failed": "The update failed.", + "settings.update.phase_idle": "Ready", + "settings.update.phase_checking": "Checking", + "settings.update.phase_checked": "Checked", + "settings.update.phase_downloading": "Downloading", + "settings.update.phase_paused_download": "Paused (Download)", + "settings.update.phase_downloaded": "Downloaded", + "settings.update.phase_installing": "Installing", + "settings.update.phase_paused_install": "Paused (Install)", + "settings.update.phase_installed": "Installed", + "settings.update.phase_verifying": "Verifying", + "settings.update.phase_completed": "Completed", + "settings.update.phase_failed": "Failed", + "settings.update.phase_recovering": "Recovering", + "settings.update.phase_rolling_back": "Rolling Back", + "settings.update.phase_rolled_back": "Rolled Back", + "settings.update.badge_available": "Update available", + "settings.update.badge_paused": "Paused", + "settings.update.paused_hint": "Paused. Resume to continue from the current state.", + "settings.update.check_button_short": "Check", + "settings.update.download_button_short": "Download", + "settings.update.install_button_short": "Install", + "settings.update.pause_button_short": "Pause", + "settings.update.resume_button_short": "Resume", + "settings.update.rollback_button_short": "Rollback", + "settings.update.cancel_button_short": "Cancel", + "settings.update.progress_download_detail_format": "{0} ({1}%)", + "settings.update.status_resumed": "Resume complete.", + "settings.update.status_resume_failed": "Resume failed.", + "settings.update.status_resume_state_invalid": "The resume state is invalid. Cancel and redownload, then try again.", + "settings.update.status_recovering": "Recovering installation...", + "settings.update.status_installing": "Installing update...", + "settings.update.status_rolling_back": "Rolling back...", + "settings.update.status_canceled": "Update canceled.", + "settings.update.download_progress_idle": "Download progress: -", + "settings.update.download_progress_format": "Download progress: {0:F0}%", + "settings.update.actions_header": "Update Actions", + "settings.update.actions_desc": "Check releases, download installer, and start update.", + "settings.update.check_button": "Check for Updates", + "settings.update.download_install_button": "Download & Install", + "settings.update.status_ready": "Ready to check for updates.", + "settings.update.status_channel_changed": "Update channel changed. Please check again.", + "settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.", + "settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.", + "settings.update.status_checking": "Checking GitHub releases...", + "settings.update.status_check_failed_format": "Update check failed: {0}", + "settings.update.status_up_to_date": "You are already on the latest version.", + "settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.", + "settings.update.status_available_format": "New version {0} is available. Click Download & Install.", + "settings.update.status_downloading": "Downloading installer...", + "settings.update.status_downloading_delta": "Downloading incremental update...", + "settings.update.status_delta_applying": "Applying incremental update. The app will close for update.", + "settings.update.status_delta_launch_failed": "Failed to launch updater for incremental update.", + "settings.update.status_download_failed_format": "Download failed: {0}", + "settings.update.status_launching_installer": "Download complete. Launching installer...", + "settings.update.status_installer_missing": "Installer file was not found after download.", + "settings.update.status_installer_started": "Installer started. The app will close for update.", + "settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.", + "settings.update.status_launch_failed_format": "Failed to start installer: {0}", + "settings.update.type_delta": "Incremental Update", + "settings.update.type_full": "Full Installer", "settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.", "settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.", - "settings.about.app_info_header": "Application Information", + "settings.update.description": "Check releases, choose the update channel and download source, and control how updates are installed.", + "settings.update.status_card_title": "Update Status", + "settings.update.status_card_description": "Check for updates, review release details, and continue with download or installation when a new version is available.", + "settings.update.release_facts_title": "Release Facts", + "settings.update.release_facts_description": "Keep the current version, published release, and update type visible without collapsing the layout while states change.", + "settings.update.progress_title": "Progress", + "settings.update.progress_description": "Watch download, installation, verification, and recovery progress here.", + "settings.update.actions_title": "Actions", + "settings.update.actions_description": "The buttons below stay in place while the update phase changes, so the page does not jump around.", + "settings.update.preferences_title": "Update Preferences", + "settings.update.preferences_description": "Choose the release channel, installer download source, installation behavior, and download parallelism.", + "settings.update.last_checked_label": "Last Checked", + "settings.update.last_checked_none": "Not checked yet.", + "settings.update.last_checked_format": "Last checked: {0}", + "settings.update.source_label": "Download Source", + "settings.update.source_github": "GitHub", + "settings.update.source_ghproxy": "gh-proxy", + "settings.update.source_github_desc": "Download release assets directly from GitHub.", + "settings.update.source_ghproxy_desc": "Use the gh-proxy mirror when downloading GitHub release assets.", + "settings.update.mode_label": "Update Mode", + "settings.update.mode_manual": "Manual Update", + "settings.update.mode_download_then_confirm": "Silent Download", + "settings.update.mode_silent_on_exit": "Silent Install", + "settings.update.mode_manual_desc": "Only check for updates. You decide when downloads and installation happen.", + "settings.update.mode_download_then_confirm_desc": "Download updates in the background and ask for confirmation before installing them.", + "settings.update.mode_silent_on_exit_desc": "Download updates in the background and install them the next time you exit the app.", + "settings.update.channel_stable_desc": "Stable builds prioritize reliability and are recommended for most users.", + "settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.", + "settings.update.download_threads_label": "Download Threads", + "settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.", + "settings.update.type_label": "Update Type", + "settings.update.status_idle": "No update check has been performed yet.", + "settings.update.status_preferences_saved": "Update preferences saved.", + "settings.update.status_check_failed": "Failed to check for updates.", + "settings.update.status_available_summary_format": "Update available: {0} (current: {1})", + "settings.update.status_up_to_date_format": "You are up to date ({0}).", + "settings.update.status_failed": "The update failed.", + "settings.update.phase_idle": "Ready", + "settings.update.phase_checking": "Checking", + "settings.update.phase_checked": "Checked", + "settings.update.phase_downloading": "Downloading", + "settings.update.phase_paused_download": "Paused (Download)", + "settings.update.phase_downloaded": "Downloaded", + "settings.update.phase_installing": "Installing", + "settings.update.phase_paused_install": "Paused (Install)", + "settings.update.phase_installed": "Installed", + "settings.update.phase_verifying": "Verifying", + "settings.update.phase_completed": "Completed", + "settings.update.phase_failed": "Failed", + "settings.update.phase_recovering": "Recovering", + "settings.update.phase_rolling_back": "Rolling Back", + "settings.update.phase_rolled_back": "Rolled Back", + "settings.update.badge_available": "Update available", + "settings.update.badge_paused": "Paused", + "settings.update.paused_hint": "Paused. Resume to continue from the current state.", + "settings.update.check_button_short": "Check", + "settings.update.download_button_short": "Download", + "settings.update.install_button_short": "Install", + "settings.update.pause_button_short": "Pause", + "settings.update.resume_button_short": "Resume", + "settings.update.rollback_button_short": "Rollback", + "settings.update.cancel_button_short": "Cancel", + "settings.update.progress_download_detail_format": "{0} ({1}%)", + "settings.update.status_resumed": "Resume complete.", + "settings.update.status_resume_failed": "Resume failed.", + "settings.update.status_resume_state_invalid": "The resume state is invalid. Cancel and redownload, then try again.", + "settings.update.status_recovering": "Recovering installation...", + "settings.update.status_installing": "Installing update...", + "settings.update.status_rolling_back": "Rolling back...", + "settings.update.status_canceled": "Update canceled.", + "settings.about.update_header": "Updates", "settings.about.version_label": "Version", "settings.about.codename_label": "Codename", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json index 61315df..f6d54d1 100644 --- a/LanMountainDesktop/Localization/ja-JP.json +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -287,6 +287,12 @@ "settings.general.preview_time_label": "時刻", "settings.general.preview_date_label": "日付", "settings.general.render_mode_restart_message": "レンダリングモードの変更にはアプリの再起動が必要です。", + "settings.general.multi_instance_behavior_header": "アプリを再度開くときの動作", + "settings.general.multi_instance_behavior_desc": "LanMountain Desktop が既に実行中の場合に、Launcher が再起動操作をどう処理するかを選択します。", + "settings.general.multi_instance_behavior.restart": "アプリを再起動", + "settings.general.multi_instance_behavior.open_silently": "通知せずにデスクトップを開く", + "settings.general.multi_instance_behavior.prompt_only": "プロンプトのみ表示", + "settings.general.multi_instance_behavior.notify_and_open": "通知してデスクトップを開く", "settings.data.title": "データ", "settings.data.description": "ローカルに保存されたアプリデータとキャッシュを確認・管理します。", "settings.appearance.title": "外観", @@ -473,7 +479,77 @@ "settings.update.install_now_button": "今すぐインストール", "settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。", "settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。", - "settings.about.app_info_header": "アプリケーション情報", + "settings.update.description": "リリースを確認し、アップデートチャンネルとダウンロードソースを選択し、アップデートのインストール方法を制御します。", + "settings.update.status_card_title": "アップデートステータス", + "settings.update.status_card_description": "アップデートを確認し、リリースの詳細を確認し、新しいバージョンが利用可能な場合はダウンロードまたはインストールを続行します。", + "settings.update.release_facts_title": "リリース情報", + "settings.update.release_facts_description": "状態が変わっても、現在のバージョン・公開済みリリース・更新タイプを折りたたまずに表示します。", + "settings.update.progress_title": "進行状況", + "settings.update.progress_description": "ダウンロード、インストール、検証、復旧の進行状況をここで確認します。", + "settings.update.actions_title": "操作", + "settings.update.actions_description": "下のボタンは更新フェーズが変わっても位置を固定し、ページが大きく動かないようにします。", + "settings.update.preferences_title": "アップデート設定", + "settings.update.preferences_description": "リリースチャンネル、インストーラーのダウンロード元、インストール方法、ダウンロードの並列度を選択します。", + "settings.update.last_checked_label": "最終確認日時", + "settings.update.last_checked_none": "まだ確認していません。", + "settings.update.last_checked_format": "最終確認: {0}", + "settings.update.source_label": "ダウンロードソース", + "settings.update.source_github": "GitHub", + "settings.update.source_ghproxy": "gh-proxy", + "settings.update.source_github_desc": "GitHubからリリースアセットを直接ダウンロードします。", + "settings.update.source_ghproxy_desc": "GitHubリリースアセットをダウンロードする際にgh-proxyミラーを使用します。", + "settings.update.mode_label": "アップデートモード", + "settings.update.mode_manual": "手動アップデート", + "settings.update.mode_download_then_confirm": "サイレントダウンロード", + "settings.update.mode_silent_on_exit": "サイレントインストール", + "settings.update.mode_manual_desc": "アップデートの確認のみ。ダウンロードとインストールのタイミングを決定します。", + "settings.update.mode_download_then_confirm_desc": "バックグラウンドでアップデートをダウンロードし、インストール前に確認を求めます。", + "settings.update.mode_silent_on_exit_desc": "バックグラウンドでアップデートをダウンロードし、アプリの終了時にインストールします。", + "settings.update.channel_stable_desc": "安定ビルドは信頼性を重視し、ほとんどのユーザーにおすすめです。", + "settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。", + "settings.update.download_threads_label": "ダウンロードスレッド", + "settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。", + "settings.update.type_label": "更新タイプ", + "settings.update.status_idle": "アップデートの確認はまだ実行されていません。", + "settings.update.status_preferences_saved": "アップデート設定が保存されました。", + "settings.update.status_check_failed": "アップデートの確認に失敗しました。", + "settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})", + "settings.update.status_up_to_date_format": "最新版です({0})。", + "settings.update.status_failed": "アップデートに失敗しました。", + "settings.update.phase_idle": "準備完了", + "settings.update.phase_checking": "確認中", + "settings.update.phase_checked": "確認済み", + "settings.update.phase_downloading": "ダウンロード中", + "settings.update.phase_paused_download": "一時停止(ダウンロード)", + "settings.update.phase_downloaded": "ダウンロード済み", + "settings.update.phase_installing": "インストール中", + "settings.update.phase_paused_install": "一時停止(インストール)", + "settings.update.phase_installed": "インストール済み", + "settings.update.phase_verifying": "検証中", + "settings.update.phase_completed": "完了", + "settings.update.phase_failed": "失敗", + "settings.update.phase_recovering": "復旧中", + "settings.update.phase_rolling_back": "ロールバック中", + "settings.update.phase_rolled_back": "ロールバック済み", + "settings.update.badge_available": "アップデートあり", + "settings.update.badge_paused": "一時停止中", + "settings.update.paused_hint": "一時停止中です。再開すると現在の状態から続行します。", + "settings.update.check_button_short": "確認", + "settings.update.download_button_short": "ダウンロード", + "settings.update.install_button_short": "インストール", + "settings.update.pause_button_short": "一時停止", + "settings.update.resume_button_short": "再開", + "settings.update.rollback_button_short": "ロールバック", + "settings.update.cancel_button_short": "キャンセル", + "settings.update.progress_download_detail_format": "{0} ({1}%)", + "settings.update.status_resumed": "再開が完了しました。", + "settings.update.status_resume_failed": "再開に失敗しました。", + "settings.update.status_resume_state_invalid": "再開状態が無効です。キャンセルして再ダウンロードしてから再試行してください。", + "settings.update.status_recovering": "インストールを復旧中…", + "settings.update.status_installing": "アップデートをインストール中…", + "settings.update.status_rolling_back": "ロールバック中…", + "settings.update.status_canceled": "アップデートをキャンセルしました。", + "settings.about.update_header": "アップデート", "settings.about.version_label": "バージョン", "settings.about.codename_label": "コードネーム", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index 973e734..4d7dd02 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -335,6 +335,12 @@ "settings.general.preview_time_label": "시간", "settings.general.preview_date_label": "날짜", "settings.general.render_mode_restart_message": "렌더링 모드 변경은 앱 재시작이 필요합니다.", + "settings.general.multi_instance_behavior_header": "앱을 다시 열 때 동작", + "settings.general.multi_instance_behavior_desc": "LanMountain Desktop이 이미 실행 중일 때 Launcher가 반복 실행을 처리하는 방식을 선택합니다.", + "settings.general.multi_instance_behavior.restart": "앱 다시 시작", + "settings.general.multi_instance_behavior.open_silently": "알림 없이 데스크톱 열기", + "settings.general.multi_instance_behavior.prompt_only": "프롬프트만 표시", + "settings.general.multi_instance_behavior.notify_and_open": "알림 후 데스크톱 열기", "settings.data.title": "데이터", "settings.data.description": "로컬에 저장된 앱 데이터와 캐시를 확인하고 관리합니다.", "settings.appearance.title": "외관", @@ -521,7 +527,77 @@ "settings.update.install_now_button": "지금 설치", "settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.", "settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.", - "settings.about.app_info_header": "앱 정보", + "settings.update.description": "업데이트 확인, 릴리스 채널 및 다운로드 소스 선택, 업데이트 설치 방법 제어.", + "settings.update.status_card_title": "업데이트 상태", + "settings.update.status_card_description": "새 버전 확인, 릴리스 정보 보기, 업데이트 시 다운로드 또는 설치 계속.", + "settings.update.release_facts_title": "릴리스 정보", + "settings.update.release_facts_description": "상태가 바뀌어도 현재 버전, 게시된 릴리스, 업데이트 유형이 접히지 않게 유지합니다.", + "settings.update.progress_title": "진행률", + "settings.update.progress_description": "여기서 다운로드, 설치, 검증, 복구 진행률을 확인하세요.", + "settings.update.actions_title": "작업", + "settings.update.actions_description": "아래 버튼은 업데이트 단계가 바뀌어도 고정되어 페이지가 크게 흔들리지 않습니다.", + "settings.update.preferences_title": "업데이트 설정", + "settings.update.preferences_description": "릴리스 채널, 설치 패키지 다운로드 소스, 설치 방식, 다운로드 병렬 처리 수를 선택합니다.", + "settings.update.last_checked_label": "마지막 확인", + "settings.update.last_checked_none": "아직 확인하지 않았습니다.", + "settings.update.last_checked_format": "마지막 확인: {0}", + "settings.update.source_label": "다운로드 소스", + "settings.update.source_github": "GitHub", + "settings.update.source_ghproxy": "gh-proxy", + "settings.update.source_github_desc": "GitHub에서 직접 릴리스 설치 패키지를 다운로드합니다.", + "settings.update.source_ghproxy_desc": "GitHub 릴리스 설치 패키지를 다운로드할 때 gh-proxy 미러를 사용합니다.", + "settings.update.mode_label": "업데이트 모드", + "settings.update.mode_manual": "수동 업데이트", + "settings.update.mode_download_then_confirm": "자동 다운로드", + "settings.update.mode_silent_on_exit": "자동 설치", + "settings.update.mode_manual_desc": "업데이트만 확인합니다. 다운로드와 설치 시기는 사용자가 결정합니다.", + "settings.update.mode_download_then_confirm_desc": "백그라운드에서 업데이트를 다운로드하고 완료 후 설치 여부를 확인합니다.", + "settings.update.mode_silent_on_exit_desc": "백그라운드에서 업데이트를 다운로드하고 다음 앱 종료 시 자동으로 설치합니다.", + "settings.update.channel_stable_desc": "정식 버전은 안정성을 우선하며 대부분의 사용자에게 적합합니다.", + "settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.", + "settings.update.download_threads_label": "다운로드 스레드 수", + "settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.", + "settings.update.type_label": "업데이트 유형", + "settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.", + "settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.", + "settings.update.status_check_failed": "업데이트 확인 실패.", + "settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).", + "settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).", + "settings.update.status_failed": "업데이트에 실패했습니다.", + "settings.update.phase_idle": "준비됨", + "settings.update.phase_checking": "확인 중", + "settings.update.phase_checked": "확인됨", + "settings.update.phase_downloading": "다운로드 중", + "settings.update.phase_paused_download": "일시 중지(다운로드)", + "settings.update.phase_downloaded": "다운로드 완료", + "settings.update.phase_installing": "설치 중", + "settings.update.phase_paused_install": "일시 중지(설치)", + "settings.update.phase_installed": "설치됨", + "settings.update.phase_verifying": "검증 중", + "settings.update.phase_completed": "완료됨", + "settings.update.phase_failed": "실패", + "settings.update.phase_recovering": "복구 중", + "settings.update.phase_rolling_back": "되돌리는 중", + "settings.update.phase_rolled_back": "되돌림 완료", + "settings.update.badge_available": "업데이트 उपलब्ध", + "settings.update.badge_paused": "일시 중지됨", + "settings.update.paused_hint": "일시 중지되었습니다. 다시 시작하면 현재 상태에서 계속합니다.", + "settings.update.check_button_short": "확인", + "settings.update.download_button_short": "다운로드", + "settings.update.install_button_short": "설치", + "settings.update.pause_button_short": "일시 중지", + "settings.update.resume_button_short": "재개", + "settings.update.rollback_button_short": "되돌리기", + "settings.update.cancel_button_short": "취소", + "settings.update.progress_download_detail_format": "{0} ({1}%)", + "settings.update.status_resumed": "재개가 완료되었습니다.", + "settings.update.status_resume_failed": "재개 실패.", + "settings.update.status_resume_state_invalid": "재개 상태가 올바르지 않습니다. 취소 후 다시 다운로드하여 시도하세요.", + "settings.update.status_recovering": "설치 복구 중…", + "settings.update.status_installing": "업데이트 설치 중…", + "settings.update.status_rolling_back": "되돌리는 중…", + "settings.update.status_canceled": "업데이트가 취소되었습니다.", + "settings.about.update_header": "업데이트", "settings.about.version_label": "버전", "settings.about.codename_label": "버전 코드명", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 6619882..281b1f1 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -352,6 +352,12 @@ "settings.general.slide_transition_desc": "在受支持的 Windows 版本上使用滑入启动过渡。启用后会关闭淡入淡出过渡。", "settings.general.show_main_window_taskbar_header": "在任务栏显示主桌面窗口", "settings.general.show_main_window_taskbar_desc": "让主桌面宿主窗口保持在任务栏中可见。独立设置窗口始终拥有自己的任务栏入口。", + "settings.general.multi_instance_behavior_header": "多次开启时的行为", + "settings.general.multi_instance_behavior_desc": "选择应用已经运行时,启动器如何处理再次打开。", + "settings.general.multi_instance_behavior.restart": "重新启动应用", + "settings.general.multi_instance_behavior.open_silently": "不弹窗直接打开桌面", + "settings.general.multi_instance_behavior.prompt_only": "弹窗但不打开桌面", + "settings.general.multi_instance_behavior.notify_and_open": "弹出通知并打开桌面", "settings.data.title": "数据", "settings.data.description": "查看与管理本机存储中的应用数据与缓存。", "settings.appearance.title": "外观", @@ -587,7 +593,77 @@ "settings.update.install_now_button": "立即安装", "settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。", "settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。", - "settings.about.app_info_header": "应用信息", + "settings.update.description": "检查更新、选择发布通道与安装方式,并控制更新行为。", + "settings.update.status_card_title": "更新状态", + "settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。", + "settings.update.release_facts_title": "发布信息", + "settings.update.release_facts_description": "在状态变化时保持当前版本、已发布版本和更新类型可见,不让布局折叠。", + "settings.update.progress_title": "进度", + "settings.update.progress_description": "在这里查看下载、安装、校验和恢复进度。", + "settings.update.actions_title": "操作", + "settings.update.actions_description": "下面的按钮会在更新阶段变化时保持固定,页面不会来回跳动。", + "settings.update.preferences_title": "更新偏好", + "settings.update.preferences_description": "选择发布通道、安装包下载源、安装行为和下载并行度。", + "settings.update.last_checked_label": "上次检查", + "settings.update.last_checked_none": "尚未检查。", + "settings.update.last_checked_format": "上次检查:{0}", + "settings.update.source_label": "下载源", + "settings.update.source_github": "GitHub", + "settings.update.source_ghproxy": "gh-proxy", + "settings.update.source_github_desc": "直接从 GitHub 下载发布安装包。", + "settings.update.source_ghproxy_desc": "下载 GitHub 发布安装包时使用 gh-proxy 镜像。", + "settings.update.mode_label": "更新模式", + "settings.update.mode_manual": "手动更新", + "settings.update.mode_download_then_confirm": "静默下载", + "settings.update.mode_silent_on_exit": "静默安装", + "settings.update.mode_manual_desc": "仅检查更新,何时下载和安装都由你决定。", + "settings.update.mode_download_then_confirm_desc": "后台下载更新,下载完成后由你确认是否安装。", + "settings.update.mode_silent_on_exit_desc": "后台下载更新,并在你下次退出应用时静默安装。", + "settings.update.channel_stable_desc": "正式版以稳定性优先,适合大多数用户。", + "settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。", + "settings.update.download_threads_label": "下载线程数", + "settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。", + "settings.update.type_label": "更新类型", + "settings.update.status_idle": "尚未执行更新检查。", + "settings.update.status_preferences_saved": "更新偏好已保存。", + "settings.update.status_check_failed": "检查更新失败。", + "settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})", + "settings.update.status_up_to_date_format": "当前已是最新版本({0})。", + "settings.update.status_failed": "更新失败。", + "settings.update.phase_idle": "就绪", + "settings.update.phase_checking": "检查中", + "settings.update.phase_checked": "已检查", + "settings.update.phase_downloading": "下载中", + "settings.update.phase_paused_download": "已暂停(下载)", + "settings.update.phase_downloaded": "已下载", + "settings.update.phase_installing": "安装中", + "settings.update.phase_paused_install": "已暂停(安装)", + "settings.update.phase_installed": "已安装", + "settings.update.phase_verifying": "校验中", + "settings.update.phase_completed": "已完成", + "settings.update.phase_failed": "失败", + "settings.update.phase_recovering": "恢复中", + "settings.update.phase_rolling_back": "回滚中", + "settings.update.phase_rolled_back": "已回滚", + "settings.update.badge_available": "发现更新", + "settings.update.badge_paused": "已暂停", + "settings.update.paused_hint": "已暂停,继续即可从当前状态恢复。", + "settings.update.check_button_short": "检查", + "settings.update.download_button_short": "下载", + "settings.update.install_button_short": "安装", + "settings.update.pause_button_short": "暂停", + "settings.update.resume_button_short": "继续", + "settings.update.rollback_button_short": "回滚", + "settings.update.cancel_button_short": "取消", + "settings.update.progress_download_detail_format": "{0}({1}%)", + "settings.update.status_resumed": "继续完成。", + "settings.update.status_resume_failed": "继续失败。", + "settings.update.status_resume_state_invalid": "恢复状态无效。请取消后重新下载再试。", + "settings.update.status_recovering": "正在恢复安装…", + "settings.update.status_installing": "正在安装更新…", + "settings.update.status_rolling_back": "正在回滚…", + "settings.update.status_canceled": "更新已取消。", + "settings.about.update_header": "更新", "settings.about.version_label": "版本", "settings.about.codename_label": "版本代号", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 026929e..b283337 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; using LanMountainDesktop.Settings.Core; +using LanMountainDesktop.Shared.Contracts.Launcher; namespace LanMountainDesktop.Models; @@ -166,6 +168,10 @@ public sealed class AppSettingsSnapshot public bool ShowInTaskbar { get; set; } = false; + [JsonConverter(typeof(JsonStringEnumConverter))] + public MultiInstanceLaunchBehavior MultiInstanceLaunchBehavior { get; set; } = + MultiInstanceLaunchBehavior.NotifyAndOpenDesktop; + public bool EnableFusedDesktop { get; set; } = false; public List DisabledPluginIds { get; set; } = []; @@ -222,33 +228,38 @@ public sealed class AppSettingsSnapshot #endregion - #region Notification Box Settings (消息盒子全局设置) + #region Notification Box Settings /// - /// 启用消息盒子功能(Windows通知监听) + /// Enables the system notification inbox component. /// public bool NotificationBoxEnabled { get; set; } = true; /// - /// 隐私模式:开启后只显示"您有新的通知",不显示具体内容 + /// Hides notification details when unread messages are present. /// public bool NotificationBoxPrivacyMode { get; set; } = false; /// - /// 被屏蔽的应用列表(不接收这些应用的通知) + /// App IDs that should not be collected by the notification box. /// public List NotificationBoxBlockedApps { get; set; } = []; /// - /// 历史记录保留天数 + /// Number of days to retain notification box history. /// public int NotificationBoxHistoryRetentionDays { get; set; } = 7; /// - /// 最大存储通知数量(防止内存无限增长) + /// Maximum number of notifications kept in memory. /// public int NotificationBoxMaxStoredCount { get; set; } = 500; + /// + /// Linux capture mode: ProxyDaemon or PassiveMonitor. + /// + public string NotificationBoxLinuxCaptureMode { get; set; } = "ProxyDaemon"; + #endregion public AppSettingsSnapshot Clone() diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs index 7388e65..cb344ad 100644 --- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs @@ -84,40 +84,40 @@ public sealed class ComponentSettingsSnapshot public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0; - #region Notification Box Component Settings (消息盒子组件设置) + #region Notification Box Component Settings /// - /// 组件内最大显示通知数量 + /// Maximum number of notifications displayed by this component. /// public int NotificationBoxMaxDisplayCount { get; set; } = 50; /// - /// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组) + /// Sort order: TimeDesc, TimeAsc, AppGroup. /// public string NotificationBoxSortOrder { get; set; } = "TimeDesc"; /// - /// 是否显示应用图标 + /// Whether to show app icons. /// public bool NotificationBoxShowAppIcon { get; set; } = true; /// - /// 是否显示时间戳 + /// Whether to show timestamps. /// public bool NotificationBoxShowTimestamp { get; set; } = true; /// - /// 时间格式:Relative(相对时间,如"5分钟前"), Absolute(绝对时间) + /// Time format: Relative or Absolute. /// public string NotificationBoxTimeFormat { get; set; } = "Relative"; /// - /// 是否按应用分组显示 + /// Whether to group notifications by app. /// public bool NotificationBoxGroupByApp { get; set; } = false; /// - /// 是否显示清除按钮 + /// Whether to show the clear button. /// public bool NotificationBoxShowClearButton { get; set; } = true; diff --git a/LanMountainDesktop/Models/NotificationItem.cs b/LanMountainDesktop/Models/NotificationItem.cs index 119b067..cee4761 100644 --- a/LanMountainDesktop/Models/NotificationItem.cs +++ b/LanMountainDesktop/Models/NotificationItem.cs @@ -3,52 +3,43 @@ using System; namespace LanMountainDesktop.Models; /// -/// 通知项数据模型 +/// Notification captured by the desktop notification box. /// public sealed class NotificationItem { - /// - /// 唯一标识 - /// public string Id { get; set; } = Guid.NewGuid().ToString(); - /// - /// 应用ID(如 WeChat, Outlook 等) - /// public string AppId { get; set; } = string.Empty; - /// - /// 应用名称 - /// public string AppName { get; set; } = string.Empty; - /// - /// 应用图标路径或Base64 - /// public string? AppIconPath { get; set; } - /// - /// 通知标题 - /// + public byte[]? AppIconBytes { get; set; } + public string Title { get; set; } = string.Empty; - /// - /// 通知内容 - /// public string Content { get; set; } = string.Empty; - /// - /// 接收时间 - /// public DateTime ReceivedTime { get; set; } = DateTime.Now; - /// - /// 是否已读 - /// - public bool IsRead { get; set; } = false; + public DateTimeOffset ReceivedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + public bool IsRead { get; set; } - /// - /// 原始通知的额外数据(用于点击跳转) - /// public string? LaunchArgs { get; set; } + + public string Platform { get; set; } = "Unknown"; + + public string? SourceNotificationId { get; set; } + + public string? DesktopEntryId { get; set; } + + public string? Aumid { get; set; } + + public string? LaunchTarget { get; set; } + + public bool CanActivate { get; set; } + + public string CaptureMode { get; set; } = "Unknown"; } diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index f911de9..316c9c0 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -1,15 +1,11 @@ using System; -using System.Diagnostics; -using System.Threading; using System.Threading.Tasks; using Avalonia; using LanMountainDesktop.DesktopHost; using LanMountainDesktop.Models; using LanMountainDesktop.Plugins; using LanMountainDesktop.Services; -using LanMountainDesktop.Services.Launcher; using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.Shared.Contracts.Launcher; namespace LanMountainDesktop; @@ -24,43 +20,6 @@ public sealed class Program AppDataPathProvider.Initialize(args); DevPluginOptions.Parse(args); RegisterGlobalExceptionLogging(); - var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args); - - using var singleInstance = AcquireSingleInstance(restartParentProcessId); - if (!singleInstance.IsPrimaryInstance) - { - if (restartParentProcessId is not null) - { - AppLogger.Warn( - "Startup", - $"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt."); - ReportLauncherStageBeforeExit(StartupStage.ActivationFailed, "Restart relaunch could not acquire the single-instance lock."); - Environment.ExitCode = HostExitCodes.RestartLockNotAcquired; - return; - } - - var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason); - if (activationAcknowledged) - { - AppLogger.Info( - "Startup", - $"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}."); - ReportLauncherStageBeforeExit(StartupStage.ActivationRedirected, "Secondary launch forwarded to the primary instance."); - Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded; - } - else - { - AppLogger.Warn( - "Startup", - $"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}."); - ReportLauncherStageBeforeExit( - StartupStage.ActivationFailed, - $"Secondary launch failed to activate the primary instance. Reason='{failureReason ?? "unknown"}'."); - Environment.ExitCode = HostExitCodes.SecondaryActivationFailed; - } - - return; - } DesktopBootstrap.InitializeStartupServices( InitializeTelemetryIdentity, @@ -76,17 +35,6 @@ public sealed class Program var renderMode = LoadConfiguredRenderMode(); StartupRenderMode = renderMode; AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'."); - App.CurrentSingleInstanceService = singleInstance; - singleInstance.StartActivationListener(() => - { - if (Avalonia.Application.Current is App app) - { - app.ActivateMainWindow(); - return; - } - - AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready."); - }); LoadChromePatchState(); InstallChromePatchersIfNeeded(); BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args); @@ -97,10 +45,6 @@ public sealed class Program AppLogger.Critical("Startup", "Application terminated during startup.", ex); throw; } - finally - { - App.CurrentSingleInstanceService = null; - } } public static AppBuilder BuildAvaloniaApp() @@ -149,41 +93,6 @@ public sealed class Program }); } - private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId) - { - var singleInstance = SingleInstanceService.CreateDefault(); - if (singleInstance.IsPrimaryInstance || restartParentProcessId is null) - { - return singleInstance; - } - - AppLogger.Info( - "Startup", - $"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock."); - singleInstance.Dispose(); - - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12); - WaitForRestartParentExit(restartParentProcessId.Value, deadline); - - while (DateTime.UtcNow < deadline) - { - var retryInstance = SingleInstanceService.CreateDefault(); - if (retryInstance.IsPrimaryInstance) - { - AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock."); - return retryInstance; - } - - retryInstance.Dispose(); - Thread.Sleep(150); - } - - AppLogger.Warn( - "Startup", - $"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}."); - return SingleInstanceService.CreateDefault(); - } - private static string LoadConfiguredRenderMode() { try @@ -243,26 +152,6 @@ public sealed class Program } } - private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc) - { - try - { - using var process = Process.GetProcessById(processId); - var remaining = deadlineUtc - DateTime.UtcNow; - if (remaining > TimeSpan.Zero) - { - process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds)); - } - } - catch (ArgumentException) - { - } - catch (Exception ex) - { - AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex); - } - } - private static void RegisterGlobalExceptionLogging() { AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => @@ -307,35 +196,6 @@ public sealed class Program }; } - private static void ReportLauncherStageBeforeExit(StartupStage stage, string message) - { - if (!LauncherIpcClient.IsLaunchedByLauncher()) - { - return; - } - - try - { - using var launcherIpcClient = new LauncherIpcClient(); - var connected = launcherIpcClient.ConnectAsync().GetAwaiter().GetResult(); - if (!connected) - { - return; - } - - launcherIpcClient.ReportProgressAsync(new StartupProgressMessage - { - Stage = stage, - ProgressPercent = 100, - Message = message - }).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - AppLogger.Warn("LauncherIpc", $"Failed to report early launcher stage '{stage}'.", ex); - } - } - private static void InitializeTelemetryIdentity() { try diff --git a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs b/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs deleted file mode 100644 index e1fa740..0000000 --- a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Buffers; -using System.Diagnostics; -using System.IO.Pipes; -using System.Text.Json; -using LanMountainDesktop.Shared.Contracts.Launcher; - -namespace LanMountainDesktop.Services.Launcher; - -/// -/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。 -/// -public class LauncherIpcClient : IDisposable -{ - private static readonly JsonSerializerOptions StartupProgressJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - private const int LengthPrefixSize = 4; - private const int ConnectTimeoutMs = 5000; - private const int ConnectRetryCount = 3; - private const int ConnectRetryBaseDelayMs = 300; - - private NamedPipeClientStream? _pipeClient; - private bool _isConnected; - private readonly object _writeLock = new(); - - public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true; - - public async Task ConnectAsync(CancellationToken cancellationToken = default) - { - for (var attempt = 1; attempt <= ConnectRetryCount; attempt++) - { - try - { - var client = new NamedPipeClientStream( - ".", - LauncherIpcConstants.PipeName, - PipeDirection.Out, - PipeOptions.Asynchronous); - - await client.ConnectAsync(ConnectTimeoutMs, cancellationToken); - _pipeClient = client; - _isConnected = true; - return true; - } - catch (TimeoutException) - { - _pipeClient?.Dispose(); - _pipeClient = null; - - if (attempt < ConnectRetryCount) - { - var delay = ConnectRetryBaseDelayMs * attempt + Random.Shared.Next(0, 100); - try - { - await Task.Delay(delay, cancellationToken); - } - catch (OperationCanceledException) - { - return false; - } - } - } - catch (OperationCanceledException) - { - return false; - } - catch (Exception ex) - { - _pipeClient?.Dispose(); - _pipeClient = null; - - if (attempt < ConnectRetryCount) - { - AppLogger.Warn("LauncherIpc", $"Connect attempt {attempt} failed: {ex.Message}, retrying..."); - var delay = ConnectRetryBaseDelayMs * attempt + Random.Shared.Next(0, 100); - try - { - await Task.Delay(delay, cancellationToken); - } - catch (OperationCanceledException) - { - return false; - } - } - else - { - AppLogger.Warn("LauncherIpc", $"Failed to connect to Launcher IPC after {ConnectRetryCount} attempts: {ex.Message}"); - } - } - } - - return false; - } - - public async Task ReportProgressAsync(StartupProgressMessage message) - { - if (!_isConnected || _pipeClient?.IsConnected != true) - { - return; - } - - try - { - var json = JsonSerializer.Serialize(message, StartupProgressJsonOptions); - var payload = System.Text.Encoding.UTF8.GetBytes(json); - var lengthPrefix = BitConverter.GetBytes(payload.Length); - Debug.Assert(lengthPrefix.Length == LengthPrefixSize); - - var buffer = ArrayPool.Shared.Rent(LengthPrefixSize + payload.Length); - try - { - Buffer.BlockCopy(lengthPrefix, 0, buffer, 0, LengthPrefixSize); - Buffer.BlockCopy(payload, 0, buffer, LengthPrefixSize, payload.Length); - - await _pipeClient.WriteAsync(buffer.AsMemory(0, LengthPrefixSize + payload.Length)).ConfigureAwait(false); - await _pipeClient.FlushAsync().ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - catch (IOException) - { - _isConnected = false; - } - catch (Exception ex) - { - AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}"); - _isConnected = false; - } - } - - public static bool IsLaunchedByLauncher() - { - return LauncherRuntimeMetadata.GetLauncherProcessId(Environment.GetCommandLineArgs()) is not null; - } - - public void Dispose() - { - _isConnected = false; - _pipeClient?.Dispose(); - } -} diff --git a/LanMountainDesktop/Services/LinuxNotificationListener.cs b/LanMountainDesktop/Services/LinuxNotificationListener.cs index 4c298b0..97e129a 100644 --- a/LanMountainDesktop/Services/LinuxNotificationListener.cs +++ b/LanMountainDesktop/Services/LinuxNotificationListener.cs @@ -1,131 +1,214 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.Versioning; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.Models; namespace LanMountainDesktop.Services; -/// -/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications -/// [SupportedOSPlatform("linux")] -internal sealed class LinuxNotificationListener : IDisposable +internal sealed class LinuxNotificationListener : IPlatformNotificationListener { - private readonly NotificationListenerService _parent; - private CancellationTokenSource? _cts; - private bool _isRunning; + private static readonly Regex DbusStringRegex = new("^\\s*string\\s+\"(?.*)\"\\s*$", RegexOptions.Compiled); + private static readonly Regex DbusUIntRegex = new("^\\s*uint32\\s+(?\\d+)\\s*$", RegexOptions.Compiled); + private static readonly Regex DesktopEntryHintRegex = new("\"desktop-entry\"\\s+variant\\s+string\\s+\"(?[^\"]+)\"", RegexOptions.Compiled); + private static readonly Regex ImagePathHintRegex = new("\"image-path\"\\s+variant\\s+string\\s+\"(?[^\"]+)\"", RegexOptions.Compiled); - public LinuxNotificationListener(NotificationListenerService parent) + private readonly NotificationListenerService _parent; + private readonly string _requestedMode; + private readonly CancellationTokenSource _cts = new(); + private Process? _monitorProcess; + private Task? _monitorTask; + private uint _nextSyntheticId = 1; + + public LinuxNotificationListener(NotificationListenerService parent, string requestedMode) { _parent = parent; + _requestedMode = string.IsNullOrWhiteSpace(requestedMode) ? "ProxyDaemon" : requestedMode; } - /// - /// 初始化并启动DBus监听 - /// - public async Task InitializeAsync() + public async Task InitializeAsync(CancellationToken cancellationToken = default) { - try + if (!OperatingSystem.IsLinux()) { - // 检查DBus环境变量 - var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS"); - if (string.IsNullOrEmpty(dbusSessionBus)) - { - Console.WriteLine("[NotificationBox] DBus Session Bus 环境变量未设置"); - return false; - } - - // 检查通知守护进程是否运行 - // 通过检查常见进程名 - var hasNotificationDaemon = await CheckNotificationDaemonAsync(); - if (!hasNotificationDaemon) - { - Console.WriteLine("[NotificationBox] 未检测到通知守护进程,消息盒子功能可能不可用"); - // 仍然返回true,因为守护进程可能在之后启动 - } - - _cts = new CancellationTokenSource(); - _ = StartListeningAsync(_cts.Token); - - Console.WriteLine("[NotificationBox] Linux通知监听已启动"); - return true; + return new NotificationBoxStatus(NotificationBoxServiceState.Unsupported, "当前平台不是 Linux。", "Linux"); } - catch (Exception ex) + + var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS"); + if (string.IsNullOrEmpty(dbusSessionBus)) { - Console.WriteLine($"[NotificationBox] Linux通知监听初始化失败: {ex.Message}"); - return false; + return new NotificationBoxStatus( + NotificationBoxServiceState.Unsupported, + "DBus Session Bus 环境变量未设置,无法监听 Linux 通知。", + _requestedMode); } + + var hasMonitorTool = CommandExists("dbus-monitor"); + if (!hasMonitorTool) + { + return new NotificationBoxStatus( + NotificationBoxServiceState.Unsupported, + "未找到 dbus-monitor,无法启用 Linux 通知旁路监听。", + _requestedMode); + } + + var mode = _requestedMode.Equals("PassiveMonitor", StringComparison.OrdinalIgnoreCase) + ? "PassiveMonitor" + : "ProxyDaemon"; + + var daemonRunning = await CheckNotificationDaemonAsync(cancellationToken).ConfigureAwait(false); + var statusMessage = mode == "ProxyDaemon" && daemonRunning + ? "系统通知守护进程已占用 org.freedesktop.Notifications,已以旁路监听方式运行。" + : mode == "ProxyDaemon" + ? "Linux 通知代理模式已启动;未检测到现有通知守护进程。" + : "Linux 通知旁路监听已启动。"; + + StartDbusMonitor(mode); + + return new NotificationBoxStatus( + mode == "ProxyDaemon" && daemonRunning ? NotificationBoxServiceState.Degraded : NotificationBoxServiceState.Running, + statusMessage, + mode); } - private async Task CheckNotificationDaemonAsync() + private void StartDbusMonitor(string mode) { - try + var startInfo = new ProcessStartInfo { - // 检查常见通知守护进程 - var processNames = new[] { "gnome-shell", "kded5", "dunst", "mako", "swaync" }; - foreach (var name in processNames) - { - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = "pgrep", - Arguments = $"-x {name}", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; + FileName = "dbus-monitor", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("--session"); + startInfo.ArgumentList.Add("interface='org.freedesktop.Notifications'"); - using var process = System.Diagnostics.Process.Start(psi); - if (process != null) - { - await process.WaitForExitAsync(); - if (process.ExitCode == 0) - { - return true; - } - } - } - return false; - } - catch + _monitorProcess = Process.Start(startInfo); + if (_monitorProcess is null) { - return false; + throw new InvalidOperationException("Failed to start dbus-monitor."); } + + _monitorTask = Task.Run(() => ReadMonitorOutputAsync(_monitorProcess, mode, _cts.Token), CancellationToken.None); } - private async Task StartListeningAsync(CancellationToken ct) + private async Task ReadMonitorOutputAsync(Process process, string mode, CancellationToken cancellationToken) { - _isRunning = true; + var capture = new List(); + var inNotify = false; - try + while (!cancellationToken.IsCancellationRequested && !process.HasExited) { - // 注意:Tmds.DBus.Protocol 是低层API - // 这里使用简化方案,实际生产环境需要完整的DBus信号订阅实现 - // 当前版本为框架实现,后续可以完善DBus监听逻辑 - - while (!ct.IsCancellationRequested && _isRunning) + var line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) { - try - { - await Task.Delay(1000, ct); - } - catch (OperationCanceledException) - { - break; - } + break; + } + + if (line.Contains("member=Notify", StringComparison.Ordinal)) + { + capture.Clear(); + inNotify = true; + continue; + } + + if (!inNotify) + { + if (line.Contains("member=NotificationClosed", StringComparison.Ordinal) || + line.Contains("member=CloseNotification", StringComparison.Ordinal)) + { + capture.Clear(); + capture.Add(line); + inNotify = false; + } + continue; + } + + if (line.StartsWith("method ", StringComparison.Ordinal) || + line.StartsWith("signal ", StringComparison.Ordinal)) + { + TryParseNotify(capture, mode); + capture.Clear(); + inNotify = line.Contains("member=Notify", StringComparison.Ordinal); + continue; + } + + capture.Add(line); + if (capture.Count > 40) + { + TryParseNotify(capture, mode); + capture.Clear(); + inNotify = false; } - } - catch (Exception ex) - { - Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}"); } } - /// - /// 处理接收到的通知(供DBus信号处理器调用) - /// + private void TryParseNotify(IReadOnlyList lines, string mode) + { + if (lines.Count == 0) + { + return; + } + + var strings = lines + .Select(line => DbusStringRegex.Match(line)) + .Where(match => match.Success) + .Select(match => UnescapeDbusString(match.Groups["value"].Value)) + .ToList(); + + if (strings.Count < 4) + { + return; + } + + var appName = strings[0]; + var appIcon = strings[1]; + var summary = strings[2]; + var body = strings[3]; + var desktopEntry = TryMatchHint(lines, DesktopEntryHintRegex); + var imagePath = TryMatchHint(lines, ImagePathHintRegex); + + var sourceId = lines + .Select(line => DbusUIntRegex.Match(line)) + .Where(match => match.Success) + .Select(match => match.Groups["value"].Value) + .Skip(1) + .FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(sourceId)) + { + sourceId = (_nextSyntheticId++).ToString(); + } + + var notification = new NotificationItem + { + Id = $"linux:{sourceId}", + SourceNotificationId = sourceId, + Platform = "Linux", + CaptureMode = mode, + AppId = !string.IsNullOrWhiteSpace(desktopEntry) + ? desktopEntry + : NormalizeAppId(appName), + AppName = string.IsNullOrWhiteSpace(appName) ? "Linux 应用" : appName, + Title = StripHtmlTags(summary), + Content = StripHtmlTags(body), + AppIconPath = ResolveIconPath(!string.IsNullOrWhiteSpace(imagePath) ? imagePath : appIcon, appName), + DesktopEntryId = string.IsNullOrWhiteSpace(desktopEntry) ? null : $"{desktopEntry}.desktop", + LaunchTarget = string.IsNullOrWhiteSpace(desktopEntry) ? null : desktopEntry, + CanActivate = !string.IsNullOrWhiteSpace(desktopEntry), + ReceivedAtUtc = DateTimeOffset.UtcNow, + ReceivedTime = DateTime.Now + }; + + _parent.AddNotification(notification); + } + public void HandleNotification( string appName, uint replacesId, @@ -136,30 +219,75 @@ internal sealed class LinuxNotificationListener : IDisposable object hints, int expireTimeout) { - try + var sourceId = replacesId == 0 ? _nextSyntheticId++ : replacesId; + var notification = new NotificationItem { - var notification = new NotificationItem - { - Id = Guid.NewGuid().ToString(), - AppId = appName.ToLowerInvariant().Replace(" ", ""), - AppName = appName, - Title = summary, - Content = StripHtmlTags(body), - ReceivedTime = DateTime.Now, - AppIconPath = ResolveIconPath(appIcon, appName) - }; + Id = $"linux:{sourceId}", + SourceNotificationId = sourceId.ToString(), + Platform = "Linux", + CaptureMode = _requestedMode, + AppId = NormalizeAppId(appName), + AppName = appName, + Title = StripHtmlTags(summary), + Content = StripHtmlTags(body), + AppIconPath = ResolveIconPath(appIcon, appName), + ReceivedAtUtc = DateTimeOffset.UtcNow, + ReceivedTime = DateTime.Now + }; - _parent.AddNotification(notification); - } - catch (Exception ex) - { - Console.WriteLine($"[NotificationBox] 处理通知失败: {ex.Message}"); - } + _parent.AddNotification(notification); + } + + private static async Task CheckNotificationDaemonAsync(CancellationToken cancellationToken) + { + var processNames = new[] { "gnome-shell", "plasmashell", "kded5", "dunst", "mako", "swaync", "xfce4-notifyd" }; + foreach (var name in processNames) + { + try + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = "pgrep", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }.WithArgument("-x").WithArgument(name)); + if (process is null) + { + continue; + } + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (process.ExitCode == 0) + { + return true; + } + } + catch + { + } + } + + return false; + } + + private static bool CommandExists(string command) + { + var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return pathEntries.Any(path => + { + try + { + return File.Exists(Path.Combine(path, command)); + } + catch + { + return false; + } + }); } - /// - /// 解析应用图标路径 - /// private static string? ResolveIconPath(string iconName, string appName) { if (string.IsNullOrEmpty(iconName)) @@ -167,13 +295,11 @@ internal sealed class LinuxNotificationListener : IDisposable return null; } - // 如果是绝对路径,直接使用 if (File.Exists(iconName)) { return iconName; } - // 尝试从图标主题中查找 var iconPaths = new[] { $"/usr/share/icons/hicolor/48x48/apps/{iconName}.png", @@ -187,9 +313,6 @@ internal sealed class LinuxNotificationListener : IDisposable return iconPaths.FirstOrDefault(File.Exists); } - /// - /// 去除HTML标签(通知内容可能包含HTML) - /// private static string StripHtmlTags(string html) { if (string.IsNullOrEmpty(html)) @@ -197,20 +320,58 @@ internal sealed class LinuxNotificationListener : IDisposable return string.Empty; } - // 简单的HTML标签去除 - var result = html; - result = System.Text.RegularExpressions.Regex.Replace(result, "<[^>]+>", ""); - result = result.Replace("<", "<"); - result = result.Replace(">", ">"); - result = result.Replace("&", "&"); - result = result.Replace(""", "\""); - return result.Trim(); + var result = Regex.Replace(html, "<[^>]+>", string.Empty); + return result + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("&", "&", StringComparison.Ordinal) + .Replace(""", "\"", StringComparison.Ordinal) + .Trim(); } + private static string NormalizeAppId(string appName) + => appName.ToLowerInvariant().Replace(" ", string.Empty, StringComparison.Ordinal); + + private static string? TryMatchHint(IEnumerable lines, Regex regex) + => lines.Select(line => regex.Match(line)) + .Where(match => match.Success) + .Select(match => UnescapeDbusString(match.Groups["value"].Value)) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + + private static string UnescapeDbusString(string value) + => value + .Replace("\\\"", "\"", StringComparison.Ordinal) + .Replace("\\n", "\n", StringComparison.Ordinal) + .Replace("\\\\", "\\", StringComparison.Ordinal); + public void Dispose() { - _isRunning = false; - _cts?.Cancel(); - _cts?.Dispose(); + _cts.Cancel(); + try + { + if (_monitorProcess is { HasExited: false }) + { + _monitorProcess.Kill(entireProcessTree: true); + } + + _monitorTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch + { + } + finally + { + _monitorProcess?.Dispose(); + _cts.Dispose(); + } + } +} + +internal static class ProcessStartInfoArgumentExtensions +{ + public static ProcessStartInfo WithArgument(this ProcessStartInfo startInfo, string argument) + { + startInfo.ArgumentList.Add(argument); + return startInfo; } } diff --git a/LanMountainDesktop/Services/NotificationListenerService.cs b/LanMountainDesktop/Services/NotificationListenerService.cs index 3eee66a..5fe7c86 100644 --- a/LanMountainDesktop/Services/NotificationListenerService.cs +++ b/LanMountainDesktop/Services/NotificationListenerService.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using LanMountainDesktop.Models; @@ -9,145 +11,195 @@ using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.Services; +public enum NotificationBoxServiceState +{ + NotStarted, + Starting, + Running, + WaitingForPermission, + Unsupported, + Degraded, + Failed +} + +public sealed record NotificationBoxStatus( + NotificationBoxServiceState State, + string Message, + string CaptureMode, + bool CanRequestPermission = false); + +internal interface IPlatformNotificationListener : IDisposable +{ + Task InitializeAsync(CancellationToken cancellationToken = default); + + Task RequestPermissionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} + /// -/// 跨平台通知监听服务 +/// Cross-platform notification aggregation service used by the notification box widget. /// public sealed class NotificationListenerService : IDisposable { private readonly List _notifications = []; private readonly object _lock = new(); private readonly ISettingsService _settingsService; - - // 平台特定的监听器 - private LinuxNotificationListener? _linuxListener; + private readonly CancellationTokenSource _disposeCts = new(); + private IPlatformNotificationListener? _platformListener; + private NotificationBoxStatus _status = new( + NotificationBoxServiceState.NotStarted, + "通知监听尚未启动。", + "None"); public event EventHandler? NotificationReceived; public event EventHandler? NotificationRemoved; + public event EventHandler? StatusChanged; public NotificationListenerService(ISettingsService settingsService) { _settingsService = settingsService; } - /// - /// 初始化并启动监听 - /// public async Task InitializeAsync() { + SetStatus(new NotificationBoxStatus(NotificationBoxServiceState.Starting, "正在启动通知监听...", "Starting")); + try { + var settings = _settingsService.LoadSnapshot(SettingsScope.App); + if (!settings.NotificationBoxEnabled) + { + SetStatus(new NotificationBoxStatus(NotificationBoxServiceState.Unsupported, "消息盒子已在设置中关闭。", "Disabled")); + return; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Windows: 使用 UserNotificationListener (需要Windows SDK) - // 当前为模拟实现 - await InitializeWindowsAsync(); + _platformListener = new WindowsNotificationListener(this); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - // Linux: 使用 DBus - await InitializeLinuxAsync(); + _platformListener = new LinuxNotificationListener(this, settings.NotificationBoxLinuxCaptureMode); } else { - // macOS 或其他平台:功能不可用 - Console.WriteLine("[NotificationBox] 当前平台不支持通知监听"); + SetStatus(new NotificationBoxStatus( + NotificationBoxServiceState.Unsupported, + "当前平台暂不支持系统通知监听。", + "Unsupported")); + return; } + + var status = await _platformListener.InitializeAsync(_disposeCts.Token).ConfigureAwait(false); + SetStatus(status); } catch (Exception ex) { - Console.WriteLine($"[NotificationBox] 初始化失败: {ex.Message}"); + SetStatus(new NotificationBoxStatus( + NotificationBoxServiceState.Failed, + $"通知监听初始化失败:{ex.Message}", + "Failed")); } } - private async Task InitializeWindowsAsync() - { - // Windows通知监听实现 - // 实际项目中需要添加Windows SDK引用并使用UserNotificationListener - // 由于需要UWP API,这里使用模拟实现 - await Task.CompletedTask; - Console.WriteLine("[NotificationBox] Windows通知监听已启动(模拟模式)"); - } + public NotificationBoxStatus GetStatus() => _status; - private async Task InitializeLinuxAsync() + public async Task RequestPermissionAsync(CancellationToken cancellationToken = default) { + if (_platformListener is null) + { + await InitializeAsync().ConfigureAwait(false); + return; + } + try { - _linuxListener = new LinuxNotificationListener(this); - var success = await _linuxListener.InitializeAsync(); - - if (!success) - { - Console.WriteLine("[NotificationBox] Linux通知监听初始化失败,可能未运行通知守护进程"); - } + await _platformListener.RequestPermissionAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { - Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}"); + SetStatus(new NotificationBoxStatus( + NotificationBoxServiceState.Failed, + $"请求通知权限失败:{ex.Message}", + _status.CaptureMode, + CanRequestPermission: true)); } } - /// - /// 添加通知(供平台监听器调用) - /// + public void SetStatus(NotificationBoxStatus status) + { + _status = status; + Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, status)); + } + public void AddNotification(NotificationItem notification) { var settings = _settingsService.LoadSnapshot(SettingsScope.App); - - // 检查全局开关 if (!settings.NotificationBoxEnabled) - return; - - // 检查是否在屏蔽列表中 - if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase)) - return; - - lock (_lock) { - _notifications.Add(notification); - CleanupOldNotifications(settings); + return; } - // 在UI线程触发事件 - Dispatcher.UIThread.InvokeAsync(() => + if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase) || + settings.NotificationBoxBlockedApps.Contains(notification.AppName, StringComparer.OrdinalIgnoreCase)) { - NotificationReceived?.Invoke(this, notification); - }); - } + return; + } + + var now = DateTimeOffset.UtcNow; + if (notification.ReceivedAtUtc == default) + { + notification.ReceivedAtUtc = now; + } + + if (notification.ReceivedTime == default) + { + notification.ReceivedTime = notification.ReceivedAtUtc.LocalDateTime; + } - /// - /// 移除通知 - /// - public void RemoveNotification(string notificationId) - { lock (_lock) { - var notification = _notifications.FirstOrDefault(n => n.Id == notificationId); - if (notification != null) + var existing = !string.IsNullOrWhiteSpace(notification.SourceNotificationId) + ? _notifications.FirstOrDefault(n => + string.Equals(n.Platform, notification.Platform, StringComparison.OrdinalIgnoreCase) && + string.Equals(n.SourceNotificationId, notification.SourceNotificationId, StringComparison.OrdinalIgnoreCase)) + : null; + + if (existing is not null) { - _notifications.Remove(notification); + CopyNotification(notification, existing); + CleanupOldNotifications(settings); + } + else + { + _notifications.Add(notification); + CleanupOldNotifications(settings); } } - NotificationRemoved?.Invoke(this, notificationId); + Dispatcher.UIThread.InvokeAsync(() => NotificationReceived?.Invoke(this, notification)); } - private void CleanupOldNotifications(AppSettingsSnapshot settings) + public void RemoveNotification(string notificationId) { - // 按数量清理 - var maxCount = settings.NotificationBoxMaxStoredCount; - while (_notifications.Count > maxCount) + var removed = false; + lock (_lock) { - _notifications.RemoveAt(0); + var notification = _notifications.FirstOrDefault(n => + string.Equals(n.Id, notificationId, StringComparison.OrdinalIgnoreCase) || + string.Equals(n.SourceNotificationId, notificationId, StringComparison.OrdinalIgnoreCase)); + if (notification != null) + { + _notifications.Remove(notification); + removed = true; + } } - // 按时间清理 - var cutoffDate = DateTime.Now.AddDays(-settings.NotificationBoxHistoryRetentionDays); - _notifications.RemoveAll(n => n.ReceivedTime < cutoffDate); + if (removed) + { + Dispatcher.UIThread.InvokeAsync(() => NotificationRemoved?.Invoke(this, notificationId)); + } } - /// - /// 获取所有通知 - /// public IReadOnlyList GetNotifications() { lock (_lock) @@ -156,20 +208,16 @@ public sealed class NotificationListenerService : IDisposable } } - /// - /// 清空所有通知 - /// public void ClearAll() { lock (_lock) { _notifications.Clear(); } + + Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, _status)); } - /// - /// 标记通知为已读 - /// public void MarkAsRead(string notificationId) { lock (_lock) @@ -182,9 +230,6 @@ public sealed class NotificationListenerService : IDisposable } } - /// - /// 获取未读通知数量 - /// public int GetUnreadCount() { lock (_lock) @@ -193,9 +238,187 @@ public sealed class NotificationListenerService : IDisposable } } + public bool TryActivate(NotificationItem notification) + { + if (!notification.CanActivate) + { + return false; + } + + if (OperatingSystem.IsWindows()) + { + return TryLaunchWindows(notification); + } + + if (OperatingSystem.IsLinux()) + { + return TryLaunchLinux(notification); + } + + return false; + } + + private static bool TryLaunchWindows(NotificationItem notification) + { + try + { + var target = notification.LaunchTarget; + if (string.IsNullOrWhiteSpace(target) && !string.IsNullOrWhiteSpace(notification.Aumid)) + { + target = $"shell:AppsFolder\\{notification.Aumid}"; + } + + if (string.IsNullOrWhiteSpace(target)) + { + return false; + } + + if (target.StartsWith("shell:AppsFolder\\", StringComparison.OrdinalIgnoreCase)) + { + Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = target, + UseShellExecute = true + }); + } + else + { + Process.Start(new ProcessStartInfo + { + FileName = target, + UseShellExecute = true + }); + } + + return true; + } + catch + { + return false; + } + } + + private static bool TryLaunchLinux(NotificationItem notification) + { + try + { + if (!string.IsNullOrWhiteSpace(notification.DesktopEntryId)) + { + var root = new LinuxDesktopEntryService().Load(); + var entry = EnumerateApps(root).FirstOrDefault(app => + string.Equals(app.RelativePath, notification.DesktopEntryId, StringComparison.OrdinalIgnoreCase) || + string.Equals(app.RelativePath, $"{notification.DesktopEntryId}.desktop", StringComparison.OrdinalIgnoreCase)); + if (entry is not null && !string.IsNullOrWhiteSpace(entry.LaunchExecutable)) + { + var startInfo = new ProcessStartInfo + { + FileName = entry.LaunchExecutable, + UseShellExecute = false + }; + foreach (var argument in entry.LaunchArguments) + { + startInfo.ArgumentList.Add(argument); + } + if (!string.IsNullOrWhiteSpace(entry.WorkingDirectory)) + { + startInfo.WorkingDirectory = entry.WorkingDirectory; + } + Process.Start(startInfo); + return true; + } + } + + if (!string.IsNullOrWhiteSpace(notification.LaunchTarget)) + { + Process.Start(new ProcessStartInfo + { + FileName = notification.LaunchTarget, + UseShellExecute = true + }); + return true; + } + } + catch + { + } + + return false; + } + + private void CleanupOldNotifications(AppSettingsSnapshot settings) + { + var maxCount = Math.Max(1, settings.NotificationBoxMaxStoredCount); + while (_notifications.Count > maxCount) + { + _notifications.RemoveAt(0); + } + + var cutoffDate = DateTime.Now.AddDays(-Math.Max(1, settings.NotificationBoxHistoryRetentionDays)); + _notifications.RemoveAll(n => n.ReceivedTime < cutoffDate); + } + + private static IEnumerable EnumerateApps(StartMenuFolderNode node) + { + foreach (var app in node.Apps) + { + yield return app; + } + + foreach (var folder in node.Folders) + { + foreach (var app in EnumerateApps(folder)) + { + yield return app; + } + } + } + + private static void CopyNotification(NotificationItem source, NotificationItem target) + { + target.AppId = source.AppId; + target.AppName = source.AppName; + target.AppIconPath = source.AppIconPath; + target.AppIconBytes = source.AppIconBytes; + target.Title = source.Title; + target.Content = source.Content; + target.ReceivedTime = source.ReceivedTime; + target.ReceivedAtUtc = source.ReceivedAtUtc; + target.LaunchArgs = source.LaunchArgs; + target.Platform = source.Platform; + target.SourceNotificationId = source.SourceNotificationId; + target.DesktopEntryId = source.DesktopEntryId; + target.Aumid = source.Aumid; + target.LaunchTarget = source.LaunchTarget; + target.CanActivate = source.CanActivate; + target.CaptureMode = source.CaptureMode; + } + public void Dispose() { - _linuxListener?.Dispose(); + _disposeCts.Cancel(); + _platformListener?.Dispose(); + _disposeCts.Dispose(); ClearAll(); } } + +public static class NotificationListenerServiceProvider +{ + private static readonly object Gate = new(); + private static NotificationListenerService? _instance; + + public static NotificationListenerService GetOrCreate(ISettingsService settingsService) + { + lock (Gate) + { + if (_instance == null) + { + _instance = new NotificationListenerService(settingsService); + _ = _instance.InitializeAsync(); + } + + return _instance; + } + } +} diff --git a/LanMountainDesktop/Services/SingleInstanceService.cs b/LanMountainDesktop/Services/SingleInstanceService.cs deleted file mode 100644 index 8ae504f..0000000 --- a/LanMountainDesktop/Services/SingleInstanceService.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System; -using System.IO.Pipes; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace LanMountainDesktop.Services; - -public sealed class SingleInstanceService : IDisposable -{ - private const byte ActivationRequestCode = 0x41; // 'A' - private const byte ActivationAckCode = 0x4B; // 'K' - private const byte ActivationNackCode = 0x4E; // 'N' - - private readonly Mutex _mutex; - private readonly string _pipeName; - private readonly CancellationTokenSource _listenCts = new(); - private readonly ManualResetEventSlim _listenerReady = new(false); - private bool _ownsMutex; - private bool _disposed; - private Task? _listenTask; - - private SingleInstanceService(string mutexName, string pipeName) - { - _mutex = new Mutex(initiallyOwned: false, mutexName); - _pipeName = pipeName; - try - { - _ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false); - } - catch (AbandonedMutexException) - { - _ownsMutex = true; - } - } - - public bool IsPrimaryInstance => _ownsMutex; - - public static SingleInstanceService CreateDefault() - { - const string appId = "LanMountainDesktop"; - var userName = Environment.UserName; - var scopeSeed = $"{appId}:{userName}"; - var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed))); - var suffix = scopeHash[..16]; - var mutexName = OperatingSystem.IsWindows() - ? $"Local\\{appId}.SingleInstance.{suffix}" - : $"{appId}.SingleInstance.{suffix}"; - return new SingleInstanceService( - mutexName, - $"{appId}.Activate.{suffix}"); - } - - public void StartActivationListener(Action onActivationRequested) - { - ArgumentNullException.ThrowIfNull(onActivationRequested); - - if (!_ownsMutex || _disposed || _listenTask is not null) - { - return; - } - - AppLogger.Info( - "SingleInstance", - $"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}."); - _listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token)); - _listenerReady.Wait(TimeSpan.FromMilliseconds(500)); - } - - public bool TryNotifyPrimaryInstance(TimeSpan timeout) - { - return TryNotifyPrimaryInstance(timeout, out _); - } - - public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason) - { - if (_ownsMutex || _disposed) - { - failureReason = _ownsMutex - ? "current_instance_is_primary" - : "single_instance_service_disposed"; - return false; - } - - try - { - using var client = new NamedPipeClientStream( - serverName: ".", - pipeName: _pipeName, - direction: PipeDirection.InOut, - options: PipeOptions.Asynchronous); - - client.Connect((int)Math.Max(1, timeout.TotalMilliseconds)); - client.WriteByte(ActivationRequestCode); - client.Flush(); - - var ack = client.ReadByte(); - var acknowledged = ack == ActivationAckCode; - if (!acknowledged) - { - failureReason = ack switch - { - ActivationNackCode => "primary_rejected_activation", - -1 => "ack_not_received", - _ => $"unexpected_ack_code_{ack}" - }; - AppLogger.Warn( - "SingleInstance", - $"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}."); - return false; - } - - failureReason = null; - AppLogger.Info( - "SingleInstance", - $"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}."); - return true; - } - catch (Exception ex) - { - failureReason = "primary_activation_handshake_exception"; - AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex); - return false; - } - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - _listenCts.Cancel(); - try - { - _listenTask?.Wait(TimeSpan.FromSeconds(1)); - } - catch - { - // Ignore listener shutdown races during process exit. - } - - _listenCts.Dispose(); - _listenerReady.Dispose(); - if (_ownsMutex) - { - try - { - _mutex.ReleaseMutex(); - } - catch (ApplicationException) - { - // Ownership may already be lost during shutdown. - } - } - - _mutex.Dispose(); - } - - private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - using var server = new NamedPipeServerStream( - _pipeName, - PipeDirection.InOut, - 1, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous); - - _listenerReady.Set(); - await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); - var buffer = new byte[1]; - var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode; - var ackCode = ActivationAckCode; - - if (!isActivationRequest) - { - ackCode = ActivationNackCode; - AppLogger.Warn( - "SingleInstance", - $"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'."); - } - else - { - try - { - onActivationRequested(); - } - catch (Exception ex) - { - ackCode = ActivationNackCode; - AppLogger.Warn("SingleInstance", "Activation callback failed.", ex); - } - } - - var ackBuffer = new[] { ackCode }; - await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false); - await server.FlushAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - AppLogger.Warn("SingleInstance", "Activation listener failed.", ex); - await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false); - } - } - } -} diff --git a/LanMountainDesktop/Services/WindowsNotificationListener.cs b/LanMountainDesktop/Services/WindowsNotificationListener.cs new file mode 100644 index 0000000..6e9fdbd --- /dev/null +++ b/LanMountainDesktop/Services/WindowsNotificationListener.cs @@ -0,0 +1,504 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +internal sealed class WindowsNotificationListener : IPlatformNotificationListener +{ + private static readonly Type? UserNotificationListenerType = + ResolveWinRtType("Windows.UI.Notifications.Management.UserNotificationListener"); + private static readonly Type? NotificationKindsType = + ResolveWinRtType("Windows.UI.Notifications.NotificationKinds"); + private static readonly Type? KnownNotificationBindingsType = + ResolveWinRtType("Windows.UI.Notifications.KnownNotificationBindings"); + private static readonly Type? AppInfoType = + ResolveWinRtType("Windows.ApplicationModel.AppInfo"); + private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod(); + private static readonly MethodInfo? AsStreamForReadMethod = ResolveAsStreamForReadMethod(); + + private readonly NotificationListenerService _parent; + private readonly Dictionary _lastSnapshot = new(StringComparer.OrdinalIgnoreCase); + private readonly CancellationTokenSource _cts = new(); + private object? _listener; + private Task? _pollTask; + + public WindowsNotificationListener(NotificationListenerService parent) + { + _parent = parent; + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + if (!OperatingSystem.IsWindows() || UserNotificationListenerType is null || + NotificationKindsType is null || AsTaskGenericMethodDefinition is null) + { + return new NotificationBoxStatus( + NotificationBoxServiceState.Unsupported, + "当前 Windows 版本不支持系统通知监听。", + "Windows"); + } + + if (!HasPackageIdentity()) + { + return new NotificationBoxStatus( + NotificationBoxServiceState.WaitingForPermission, + "缺少 Windows 包身份。请使用带通知身份包的安装版本,以便系统授予通知监听权限。", + "Windows", + CanRequestPermission: false); + } + + _listener = GetPropertyValue(UserNotificationListenerType, "Current"); + if (_listener is null) + { + return new NotificationBoxStatus( + NotificationBoxServiceState.Unsupported, + "无法创建 Windows 通知监听器。", + "Windows"); + } + + var accessStatus = ReadAccessStatus(_listener); + if (!string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase)) + { + accessStatus = await RequestAccessCoreAsync(cancellationToken).ConfigureAwait(false); + } + + if (!string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase)) + { + return new NotificationBoxStatus( + NotificationBoxServiceState.WaitingForPermission, + accessStatus.Equals("Denied", StringComparison.OrdinalIgnoreCase) + ? "Windows 已拒绝通知监听权限,请在系统设置中允许阑山桌面读取通知。" + : "等待用户授予 Windows 通知监听权限。", + "Windows", + CanRequestPermission: true); + } + + await SyncNotificationsAsync(cancellationToken).ConfigureAwait(false); + _pollTask = Task.Run(() => PollLoopAsync(_cts.Token), CancellationToken.None); + + return new NotificationBoxStatus( + NotificationBoxServiceState.Running, + "Windows 系统通知监听已启动。", + "Windows"); + } + + public async Task RequestPermissionAsync(CancellationToken cancellationToken = default) + { + if (_listener is null) + { + await InitializeAsync(cancellationToken).ConfigureAwait(false); + return; + } + + var accessStatus = await RequestAccessCoreAsync(cancellationToken).ConfigureAwait(false); + _parent.SetStatus(string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase) + ? new NotificationBoxStatus(NotificationBoxServiceState.Running, "Windows 系统通知监听已启动。", "Windows") + : new NotificationBoxStatus( + NotificationBoxServiceState.WaitingForPermission, + "Windows 通知监听权限尚未授予。", + "Windows", + CanRequestPermission: true)); + + if (string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase)) + { + await SyncNotificationsAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task PollLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); + await SyncNotificationsAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _parent.SetStatus(new NotificationBoxStatus( + NotificationBoxServiceState.Degraded, + $"Windows 通知同步遇到问题:{ex.Message}", + "Windows")); + } + } + } + + private async Task SyncNotificationsAsync(CancellationToken cancellationToken) + { + if (_listener is null) + { + return; + } + + var operation = InvokeMethod(_listener, "GetNotificationsAsync", [ParseNotificationKindsToast()]); + var notificationsObject = await AwaitWinRtOperationAsync(operation, cancellationToken).ConfigureAwait(false); + if (notificationsObject is not System.Collections.IEnumerable notifications) + { + return; + } + + var currentIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var notificationObject in notifications) + { + var item = await TryMapNotificationAsync(notificationObject, cancellationToken).ConfigureAwait(false); + if (item is null || string.IsNullOrWhiteSpace(item.SourceNotificationId)) + { + continue; + } + + currentIds.Add(item.SourceNotificationId); + _lastSnapshot[item.SourceNotificationId] = item; + _parent.AddNotification(item); + } + + foreach (var removedId in _lastSnapshot.Keys.Where(id => !currentIds.Contains(id)).ToList()) + { + _lastSnapshot.Remove(removedId); + _parent.RemoveNotification(removedId); + } + } + + private async Task TryMapNotificationAsync(object? notification, CancellationToken cancellationToken) + { + if (notification is null) + { + return null; + } + + try + { + var sourceId = ReadUIntProperty(notification, "Id").ToString(); + var creationTime = ReadDateTimeOffsetProperty(notification, "CreationTime") ?? DateTimeOffset.UtcNow; + var appInfo = GetPropertyValue(notification, "AppInfo"); + var displayInfo = GetPropertyValue(appInfo, "DisplayInfo"); + var appName = ReadStringProperty(displayInfo, "DisplayName"); + var aumid = ReadStringProperty(appInfo, "AppUserModelId"); + + if (string.IsNullOrWhiteSpace(aumid)) + { + aumid = TryReadPackageFamilyName(appInfo); + } + + var (title, body) = ReadToastText(notification); + if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(body)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(appName)) + { + appName = SimplifyAppId(aumid); + } + + var iconBytes = await TryReadAppLogoAsync(displayInfo, cancellationToken).ConfigureAwait(false); + + return new NotificationItem + { + Id = $"windows:{sourceId}", + SourceNotificationId = sourceId, + Platform = "Windows", + CaptureMode = "WindowsUserNotificationListener", + AppId = string.IsNullOrWhiteSpace(aumid) ? appName : aumid, + AppName = string.IsNullOrWhiteSpace(appName) ? "Windows 应用" : appName, + Aumid = string.IsNullOrWhiteSpace(aumid) ? null : aumid, + LaunchTarget = string.IsNullOrWhiteSpace(aumid) ? null : $"shell:AppsFolder\\{aumid}", + CanActivate = !string.IsNullOrWhiteSpace(aumid), + Title = title, + Content = body, + ReceivedAtUtc = creationTime.ToUniversalTime(), + ReceivedTime = creationTime.LocalDateTime, + AppIconBytes = iconBytes + }; + } + catch + { + return null; + } + } + + private static (string Title, string Body) ReadToastText(object notification) + { + var notificationPayload = GetPropertyValue(notification, "Notification"); + var visual = GetPropertyValue(notificationPayload, "Visual"); + var toastGeneric = GetPropertyValue(KnownNotificationBindingsType, "ToastGeneric"); + var binding = InvokeMethod(visual, "GetBinding", [toastGeneric]); + var textElements = InvokeMethod(binding, "GetTextElements", null) as System.Collections.IEnumerable; + if (textElements is null) + { + return (string.Empty, string.Empty); + } + + var texts = new List(); + foreach (var element in textElements) + { + var text = ReadStringProperty(element, "Text"); + if (!string.IsNullOrWhiteSpace(text)) + { + texts.Add(text); + } + } + + return texts.Count switch + { + 0 => (string.Empty, string.Empty), + 1 => (texts[0], string.Empty), + _ => (texts[0], string.Join(Environment.NewLine, texts.Skip(1))) + }; + } + + private static async Task TryReadAppLogoAsync(object? displayInfo, CancellationToken cancellationToken) + { + if (displayInfo is null || AsStreamForReadMethod is null) + { + return null; + } + + try + { + var sizeType = ResolveWinRtType("Windows.Foundation.Size"); + object size = sizeType is not null + ? Activator.CreateInstance(sizeType, 32d, 32d)! + : null!; + if (size is null) + { + return null; + } + + var logoReference = InvokeMethod(displayInfo, "GetLogo", [size]); + var streamObject = await AwaitWinRtOperationAsync(InvokeMethod(logoReference, "OpenReadAsync", null), cancellationToken) + .ConfigureAwait(false); + using var dotnetStream = AsStreamForReadMethod.Invoke(null, [streamObject]) as Stream; + if (dotnetStream is null) + { + return null; + } + + using var buffer = new MemoryStream(); + await dotnetStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + return buffer.ToArray(); + } + catch + { + return null; + } + } + + private static object ParseNotificationKindsToast() + { + return Enum.Parse(NotificationKindsType!, "Toast"); + } + + private static string ReadAccessStatus(object listener) + { + return InvokeMethod(listener, "GetAccessStatus", null)?.ToString() ?? "Unspecified"; + } + + private async Task RequestAccessCoreAsync(CancellationToken cancellationToken) + { + if (_listener is null) + { + return "Unspecified"; + } + + var result = await AwaitWinRtOperationAsync(InvokeMethod(_listener, "RequestAccessAsync", null), cancellationToken) + .ConfigureAwait(false); + return result?.ToString() ?? "Unspecified"; + } + + private static bool HasPackageIdentity() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + var length = 0; + var hr = GetCurrentPackageFullName(ref length, null); + if (hr == AppmodelErrorNoPackage) + { + return false; + } + + if (length <= 0) + { + return hr == 0; + } + + var builder = new StringBuilder(length); + hr = GetCurrentPackageFullName(ref length, builder); + return hr == 0; + } + + private static string TryReadPackageFamilyName(object? appInfo) + { + var package = GetPropertyValue(appInfo, "Package"); + var id = GetPropertyValue(package, "Id"); + return ReadStringProperty(id, "FamilyName"); + } + + private static string SimplifyAppId(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Windows 应用"; + } + + var text = value; + var bangIndex = text.IndexOf('!'); + if (bangIndex > 0) + { + text = text[..bangIndex]; + } + + if (text.Contains('_')) + { + text = text.Split('_')[0]; + } + + if (text.Contains('.')) + { + text = text.Split('.', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? text; + } + + return text.Replace('_', ' ').Replace('-', ' ').Trim(); + } + + private static async Task AwaitWinRtOperationAsync(object? operation, CancellationToken cancellationToken) + { + if (operation is null || AsTaskGenericMethodDefinition is null) + { + return null; + } + + var resultType = ResolveWinRtOperationResultType(operation.GetType()); + if (resultType is null) + { + return null; + } + + var asTaskMethod = AsTaskGenericMethodDefinition.MakeGenericMethod(resultType); + var taskObject = asTaskMethod.Invoke(null, [operation]) as Task; + if (taskObject is null) + { + return null; + } + + await taskObject.WaitAsync(cancellationToken).ConfigureAwait(false); + return taskObject.GetType().GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?.GetValue(taskObject); + } + + private static Type? ResolveWinRtOperationResultType(Type operationType) + { + if (operationType.IsGenericType && operationType.GetGenericArguments().Length == 1) + { + return operationType.GetGenericArguments()[0]; + } + + foreach (var iface in operationType.GetInterfaces()) + { + if (iface.IsGenericType && + string.Equals(iface.GetGenericTypeDefinition().FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal)) + { + return iface.GetGenericArguments()[0]; + } + } + + return null; + } + + private static MethodInfo? ResolveAsTaskGenericMethod() + { + var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false); + return type? + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => method.Name == "AsTask" && method.IsGenericMethodDefinition && method.GetParameters().Length == 1); + } + + private static MethodInfo? ResolveAsStreamForReadMethod() + { + var type = Type.GetType("System.IO.WindowsRuntimeStreamExtensions, System.Runtime.WindowsRuntime", throwOnError: false); + return type? + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => method.Name == "AsStreamForRead" && method.GetParameters().Length == 1); + } + + private static Type? ResolveWinRtType(string typeName) + { + return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false); + } + + private static object? InvokeMethod(object? target, string methodName, object?[]? parameters) + { + return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, parameters); + } + + private static object? GetPropertyValue(object? target, string propertyName) + { + return target switch + { + null => null, + Type type => type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Static)?.GetValue(null), + _ => target.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target) + }; + } + + private static string ReadStringProperty(object? target, string propertyName) + { + return GetPropertyValue(target, propertyName)?.ToString()?.Trim() ?? string.Empty; + } + + private static uint ReadUIntProperty(object? target, string propertyName) + { + var value = GetPropertyValue(target, propertyName); + try + { + return Convert.ToUInt32(value); + } + catch + { + return 0; + } + } + + private static DateTimeOffset? ReadDateTimeOffsetProperty(object? target, string propertyName) + { + var value = GetPropertyValue(target, propertyName); + if (value is DateTimeOffset dateTimeOffset) + { + return dateTimeOffset; + } + + return null; + } + + public void Dispose() + { + _cts.Cancel(); + try + { + _pollTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch + { + } + _cts.Dispose(); + } + + private const int AppmodelErrorNoPackage = 15700; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFullName(ref int packageFullNameLength, StringBuilder? packageFullName); +} diff --git a/LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs b/LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs index b7aa137..b5c85e2 100644 --- a/LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs +++ b/LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs @@ -18,10 +18,10 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase MaxDisplayCountOptions = new ObservableCollection { - new("20", "20条"), - new("50", "50条"), - new("100", "100条"), - new("200", "200条") + new("20", "20 条"), + new("50", "50 条"), + new("100", "100 条"), + new("200", "200 条") }; SortOrderOptions = new ObservableCollection @@ -33,7 +33,7 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase TimeFormatOptions = new ObservableCollection { - new("Relative", "相对时间(如:5分钟前)"), + new("Relative", "相对时间(如:5 分钟前)"), new("Absolute", "绝对时间(如:14:30)") }; @@ -49,7 +49,7 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase var countValue = snapshot.NotificationBoxMaxDisplayCount.ToString(); SelectedMaxDisplayCount = MaxDisplayCountOptions.FirstOrDefault(o => o.Value == countValue) - ?? MaxDisplayCountOptions[1]; // 默认50 + ?? MaxDisplayCountOptions[1]; SelectedSortOrder = SortOrderOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxSortOrder) ?? SortOrderOptions[0]; @@ -78,7 +78,6 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase snapshot.NotificationBoxShowClearButton = ShowClearButton; _context.ComponentSettingsAccessor.SaveSnapshot(snapshot); - _context.HostContext.RequestRefresh(); } @@ -98,7 +97,7 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase [ObservableProperty] private bool _showAppIcon = true; [ObservableProperty] private bool _showTimestamp = true; [ObservableProperty] private SelectionOption? _selectedTimeFormat; - [ObservableProperty] private bool _groupByApp = false; + [ObservableProperty] private bool _groupByApp; [ObservableProperty] private bool _showClearButton = true; public ObservableCollection MaxDisplayCountOptions { get; } diff --git a/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs index ab323fc..3b95e3f 100644 --- a/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs @@ -26,6 +26,7 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase Durations = CreateDurationOptions(); TestPositions = CreatePositionOptions(); TestSeverities = CreateSeverityOptions(); + LinuxCaptureModes = CreateLinuxCaptureModeOptions(); RefreshLocalizedText(); LoadSettings(); @@ -45,6 +46,11 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase IsHoverPauseEnabled = snapshot.NotificationHoverPauseEnabled; IsClickCloseEnabled = snapshot.NotificationClickCloseEnabled; MaxNotificationsPerPosition = snapshot.NotificationMaxPerPosition; + IsNotificationBoxEnabled = snapshot.NotificationBoxEnabled; + IsNotificationBoxPrivacyMode = snapshot.NotificationBoxPrivacyMode; + SelectedLinuxCaptureMode = LinuxCaptureModes.FirstOrDefault(o => + string.Equals(o.Value, snapshot.NotificationBoxLinuxCaptureMode, StringComparison.OrdinalIgnoreCase)) + ?? LinuxCaptureModes[0]; SelectedPosition = Positions.FirstOrDefault(p => string.Equals(p.Value, snapshot.NotificationDefaultPosition, StringComparison.OrdinalIgnoreCase)) @@ -69,6 +75,9 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase snapshot.NotificationHoverPauseEnabled = IsHoverPauseEnabled; snapshot.NotificationClickCloseEnabled = IsClickCloseEnabled; snapshot.NotificationMaxPerPosition = MaxNotificationsPerPosition; + snapshot.NotificationBoxEnabled = IsNotificationBoxEnabled; + snapshot.NotificationBoxPrivacyMode = IsNotificationBoxPrivacyMode; + snapshot.NotificationBoxLinuxCaptureMode = SelectedLinuxCaptureMode?.Value ?? "ProxyDaemon"; _settingsFacade.Settings.SaveSnapshot( SettingsScope.App, @@ -80,7 +89,10 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase nameof(AppSettingsSnapshot.NotificationDurationSeconds), nameof(AppSettingsSnapshot.NotificationHoverPauseEnabled), nameof(AppSettingsSnapshot.NotificationClickCloseEnabled), - nameof(AppSettingsSnapshot.NotificationMaxPerPosition) + nameof(AppSettingsSnapshot.NotificationMaxPerPosition), + nameof(AppSettingsSnapshot.NotificationBoxEnabled), + nameof(AppSettingsSnapshot.NotificationBoxPrivacyMode), + nameof(AppSettingsSnapshot.NotificationBoxLinuxCaptureMode) ]); } @@ -121,6 +133,15 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase ]; } + private ObservableCollection CreateLinuxCaptureModeOptions() + { + return + [ + new SelectionOption("ProxyDaemon", "代理守护进程"), + new SelectionOption("PassiveMonitor", "旁路监听") + ]; + } + private void RefreshLocalizedText() { NotificationHeader = L("settings.notifications.section_header", "Notifications"); @@ -133,6 +154,13 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase ClickCloseDescription = L("settings.notifications.click_close_desc", "Dismiss when clicked."); MaxNotificationsHeader = L("settings.notifications.max_header", "Max per position"); MaxNotificationsDescription = L("settings.notifications.max_desc", "Maximum notifications per corner or edge."); + NotificationBoxHeader = L("settings.notifications.box_header", "Message box"); + NotificationBoxEnabledHeader = L("settings.notifications.box_enable_header", "Collect system notifications"); + NotificationBoxEnabledDescription = L("settings.notifications.box_enable_desc", "Aggregate OS notifications in the desktop message box."); + NotificationBoxPrivacyHeader = L("settings.notifications.box_privacy_header", "Privacy mode"); + NotificationBoxPrivacyDescription = L("settings.notifications.box_privacy_desc", "Hide notification details until you open the box."); + LinuxCaptureModeHeader = L("settings.notifications.linux_capture_header", "Linux capture mode"); + LinuxCaptureModeDescription = L("settings.notifications.linux_capture_desc", "Proxy mode is more reliable; passive mode is best effort."); TestHeader = L("settings.notifications.test_header", "Test"); TestNotificationHeader = L("settings.notifications.test_notification_header", "Test notification"); TestNotificationDescription = L("settings.notifications.test_notification_desc", "Send a sample notification."); @@ -173,6 +201,20 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _maxNotificationsDescription = string.Empty; + [ObservableProperty] private string _notificationBoxHeader = string.Empty; + + [ObservableProperty] private string _notificationBoxEnabledHeader = string.Empty; + + [ObservableProperty] private string _notificationBoxEnabledDescription = string.Empty; + + [ObservableProperty] private string _notificationBoxPrivacyHeader = string.Empty; + + [ObservableProperty] private string _notificationBoxPrivacyDescription = string.Empty; + + [ObservableProperty] private string _linuxCaptureModeHeader = string.Empty; + + [ObservableProperty] private string _linuxCaptureModeDescription = string.Empty; + [ObservableProperty] private string _testHeader = string.Empty; [ObservableProperty] private string _testNotificationHeader = string.Empty; @@ -187,6 +229,10 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase [ObservableProperty] private int _maxNotificationsPerPosition = 5; + [ObservableProperty] private bool _isNotificationBoxEnabled = true; + + [ObservableProperty] private bool _isNotificationBoxPrivacyMode; + [ObservableProperty] private SelectionOption? _selectedPosition; [ObservableProperty] private SelectionOption? _selectedDuration; @@ -195,6 +241,8 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase [ObservableProperty] private SelectionOption? _selectedTestSeverity; + [ObservableProperty] private SelectionOption? _selectedLinuxCaptureMode; + [ObservableProperty] private int _testDurationSeconds = 4; public ObservableCollection Positions { get; } @@ -202,6 +250,8 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase public ObservableCollection TestPositions { get; } public ObservableCollection TestSeverities { get; } + public ObservableCollection LinuxCaptureModes { get; } + partial void OnIsNotificationEnabledChanged(bool value) => SaveSettings(); partial void OnIsHoverPauseEnabledChanged(bool value) => SaveSettings(); @@ -210,10 +260,16 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase partial void OnMaxNotificationsPerPositionChanged(int value) => SaveSettings(); + partial void OnIsNotificationBoxEnabledChanged(bool value) => SaveSettings(); + + partial void OnIsNotificationBoxPrivacyModeChanged(bool value) => SaveSettings(); + partial void OnSelectedPositionChanged(SelectionOption? value) => SaveSettings(); partial void OnSelectedDurationChanged(SelectionOption? value) => SaveSettings(); + partial void OnSelectedLinuxCaptureModeChanged(SelectionOption? value) => SaveSettings(); + [RelayCommand] private void SendTest() { diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 2a3ba5b..eeec2db 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -236,6 +236,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo Languages = CreateLanguageOptions(); RenderModes = CreateRenderModeOptions(); + MultiInstanceLaunchBehaviors = CreateMultiInstanceLaunchBehaviorOptions(); TimeZones = CreateTimeZoneOptions(); RefreshLocalizedText(); @@ -252,6 +253,10 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo SelectedRenderMode = RenderModes.FirstOrDefault(option => string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase)) ?? RenderModes[0]; + SelectedMultiInstanceLaunchBehavior = MultiInstanceLaunchBehaviors.FirstOrDefault(option => + string.Equals(option.Value, appSnapshot.MultiInstanceLaunchBehavior.ToString(), StringComparison.OrdinalIgnoreCase)) + ?? MultiInstanceLaunchBehaviors.First(option => + string.Equals(option.Value, MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(), StringComparison.OrdinalIgnoreCase)); ApplyTransitionPreferences(appSnapshot.EnableFadeTransition, appSnapshot.EnableSlideTransition); ShowInTaskbar = appSnapshot.ShowInTaskbar; _isInitializing = false; @@ -297,6 +302,15 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo { ShowInTaskbar = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App).ShowInTaskbar; } + + if (changedKeys.Contains(nameof(AppSettingsSnapshot.MultiInstanceLaunchBehavior))) + { + var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + SelectedMultiInstanceLaunchBehavior = MultiInstanceLaunchBehaviors.FirstOrDefault(option => + string.Equals(option.Value, snapshot.MultiInstanceLaunchBehavior.ToString(), StringComparison.OrdinalIgnoreCase)) + ?? MultiInstanceLaunchBehaviors.First(option => + string.Equals(option.Value, MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(), StringComparison.OrdinalIgnoreCase)); + } } public event Action? RestartRequested; @@ -305,6 +319,8 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo public IReadOnlyList RenderModes { get; } + public IReadOnlyList MultiInstanceLaunchBehaviors { get; } + public IReadOnlyList TimeZones { get; } [ObservableProperty] @@ -316,6 +332,10 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo [ObservableProperty] private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default"); + [ObservableProperty] + private SelectionOption _selectedMultiInstanceLaunchBehavior = + new(MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(), "Notify and open desktop"); + [ObservableProperty] private bool _enableFadeTransition = true; @@ -340,6 +360,12 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo [ObservableProperty] private string _showInTaskbarDescription = string.Empty; + [ObservableProperty] + private string _multiInstanceLaunchBehaviorHeader = string.Empty; + + [ObservableProperty] + private string _multiInstanceLaunchBehaviorDescription = string.Empty; + public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition; @@ -447,6 +473,21 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo } } + partial void OnSelectedMultiInstanceLaunchBehaviorChanged(SelectionOption value) + { + if (_isInitializing || value is null) + { + return; + } + + if (!Enum.TryParse(value.Value, ignoreCase: true, out var behavior)) + { + behavior = MultiInstanceLaunchBehavior.NotifyAndOpenDesktop; + } + + SaveField(nameof(AppSettingsSnapshot.MultiInstanceLaunchBehavior), behavior); + } + partial void OnEnableSlideTransitionChanged(bool value) { if (_isInitializing) @@ -537,6 +578,25 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo ]; } + private IReadOnlyList CreateMultiInstanceLaunchBehaviorOptions() + { + return + [ + new SelectionOption( + MultiInstanceLaunchBehavior.RestartApp.ToString(), + L("settings.general.multi_instance_behavior.restart", "Restart app")), + new SelectionOption( + MultiInstanceLaunchBehavior.OpenDesktopSilently.ToString(), + L("settings.general.multi_instance_behavior.open_silently", "Open desktop without prompt")), + new SelectionOption( + MultiInstanceLaunchBehavior.PromptOnly.ToString(), + L("settings.general.multi_instance_behavior.prompt_only", "Show prompt only")), + new SelectionOption( + MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(), + L("settings.general.multi_instance_behavior.notify_and_open", "Notify and open desktop")) + ]; + } + private IReadOnlyList CreateTimeZoneOptions() { return _timeZoneService @@ -576,6 +636,12 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo ShowInTaskbarDescription = L( "settings.general.show_main_window_taskbar_desc", "Keep the main desktop host window visible in the taskbar. The independent settings window always has its own taskbar entry."); + MultiInstanceLaunchBehaviorHeader = L( + "settings.general.multi_instance_behavior_header", + "When opening the app again"); + MultiInstanceLaunchBehaviorDescription = L( + "settings.general.multi_instance_behavior_desc", + "Choose how Launcher handles repeated launches while LanMountain Desktop is already running."); } private void RefreshPreview() diff --git a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs index e755330..86463b8 100644 --- a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs +++ b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs @@ -1,8 +1,10 @@ using System; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Update; using LanMountainDesktop.Shared.Contracts.Update; @@ -14,16 +16,22 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable { private readonly UpdateOrchestrator _orchestrator; private readonly ISettingsFacadeService _settingsFacade; + private readonly LocalizationService _localizationService; + private readonly string _languageCode; private bool _disposed; public UpdateSettingsViewModel(UpdateOrchestrator orchestrator, ISettingsFacadeService settingsFacade) { _orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator)); _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _localizationService = new LocalizationService(); + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); CurrentPhase = _orchestrator.CurrentPhase; CurrentVersionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); + RefreshLocalizedText(); LoadPreferenceState(); + StatusMessage = GetPhaseStatusText(CurrentPhase); _orchestrator.PhaseChanged += OnOrchestratorPhaseChanged; _orchestrator.ProgressChanged += OnOrchestratorProgressChanged; @@ -34,10 +42,47 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable [ObservableProperty] private double _progressFraction; [ObservableProperty] private string _progressDetail = string.Empty; + [ObservableProperty] private string _pageTitle = string.Empty; + [ObservableProperty] private string _pageDescription = string.Empty; + [ObservableProperty] private string _statusSectionHeader = string.Empty; + [ObservableProperty] private string _checkCardTitle = string.Empty; + [ObservableProperty] private string _statusCardTitle = string.Empty; + [ObservableProperty] private string _statusCardDescription = string.Empty; + [ObservableProperty] private string _releaseFactsTitle = string.Empty; + [ObservableProperty] private string _releaseFactsDescription = string.Empty; + [ObservableProperty] private string _progressTitle = string.Empty; + [ObservableProperty] private string _progressDescription = string.Empty; + [ObservableProperty] private string _actionsTitle = string.Empty; + [ObservableProperty] private string _actionsDescription = string.Empty; + [ObservableProperty] private string _preferencesTitle = string.Empty; + [ObservableProperty] private string _preferencesDescription = string.Empty; + + [ObservableProperty] private string _currentVersionLabel = string.Empty; + [ObservableProperty] private string _latestVersionLabel = string.Empty; + [ObservableProperty] private string _publishedAtLabel = string.Empty; + [ObservableProperty] private string _lastCheckedLabel = string.Empty; + [ObservableProperty] private string _updateTypeLabel = string.Empty; + [ObservableProperty] private string _channelLabel = string.Empty; + [ObservableProperty] private string _sourceLabel = string.Empty; + [ObservableProperty] private string _modeLabel = string.Empty; + [ObservableProperty] private string _downloadThreadsLabel = string.Empty; + + [ObservableProperty] private string _updateAvailableBadgeText = string.Empty; + [ObservableProperty] private string _pausedBadgeText = string.Empty; + [ObservableProperty] private string _pausedHintText = string.Empty; + [ObservableProperty] private string _lastCheckedText = string.Empty; + + [ObservableProperty] private string _checkButtonText = string.Empty; + [ObservableProperty] private string _downloadButtonText = string.Empty; + [ObservableProperty] private string _installButtonText = string.Empty; + [ObservableProperty] private string _pauseButtonText = string.Empty; + [ObservableProperty] private string _resumeButtonText = string.Empty; + [ObservableProperty] private string _rollbackButtonText = string.Empty; + [ObservableProperty] private string _cancelButtonText = string.Empty; + [ObservableProperty] private string _currentVersionText = string.Empty; [ObservableProperty] private string _latestVersionText = string.Empty; [ObservableProperty] private string _publishedAtText = string.Empty; - [ObservableProperty] private string _lastCheckedText = string.Empty; [ObservableProperty] private string _updateTypeText = string.Empty; [ObservableProperty] private bool _isUpdateAvailable; [ObservableProperty] private bool _isDeltaUpdate; @@ -47,6 +92,14 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable [ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm; [ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads; + [ObservableProperty] private SelectionOption? _selectedChannel; + [ObservableProperty] private SelectionOption? _selectedSource; + [ObservableProperty] private SelectionOption? _selectedMode; + + public IReadOnlyList ChannelOptions { get; private set; } = []; + public IReadOnlyList SourceOptions { get; private set; } = []; + public IReadOnlyList ModeOptions { get; private set; } = []; + public bool IsBusy => CurrentPhase.IsBusy(); public bool IsPaused => CurrentPhase.IsPaused(); public bool CanCheck => CurrentPhase.CanCheck(); @@ -56,14 +109,12 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable public bool CanPause => CurrentPhase.CanPause(); public bool CanResume => CurrentPhase.CanResume(); public bool CanCancel => CurrentPhase.CanCancel(); - public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack; - public string PhaseText => CurrentPhase switch - { - UpdatePhase.PausedDownloading => "Paused (Download)", - UpdatePhase.PausedInstalling => "Paused (Install)", - UpdatePhase.Recovering => "Recovering Install", - _ => CurrentPhase.ToString() - }; + public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack or UpdatePhase.Recovering; + public bool IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused; + public string PhaseText => GetPhaseText(CurrentPhase); + public string LatestVersionDisplayText => string.IsNullOrEmpty(LatestVersionText) + ? L("settings.update.latest_version_none", "Up to date") + : LatestVersionText; partial void OnCurrentPhaseChanged(UpdatePhase value) { @@ -77,6 +128,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable OnPropertyChanged(nameof(CanResume)); OnPropertyChanged(nameof(CanCancel)); OnPropertyChanged(nameof(IsProgressVisible)); + OnPropertyChanged(nameof(IsProgressSectionVisible)); OnPropertyChanged(nameof(PhaseText)); CheckCommand.NotifyCanExecuteChanged(); DownloadCommand.NotifyCanExecuteChanged(); @@ -102,6 +154,30 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable SavePreferenceState(); } + partial void OnSelectedChannelChanged(SelectionOption? value) + { + if (value is not null) + { + SelectedUpdateChannelValue = value.Value; + } + } + + partial void OnSelectedSourceChanged(SelectionOption? value) + { + if (value is not null) + { + SelectedUpdateSourceValue = value.Value; + } + } + + partial void OnSelectedModeChanged(SelectionOption? value) + { + if (value is not null) + { + SelectedUpdateModeValue = value.Value; + } + } + partial void OnDownloadThreadsSliderValueChanged(double value) { SavePreferenceState(); @@ -110,15 +186,23 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable [RelayCommand(CanExecute = nameof(CanCheck))] private async Task CheckAsync() { + StatusMessage = GetCheckingStatusText(); var report = await _orchestrator.CheckAsync(CancellationToken.None); + LastCheckedText = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.last_checked_format", "Last checked: {0}"), + DateTimeOffset.Now.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)); + if (report.IsUpdateAvailable) { IsUpdateAvailable = true; LatestVersionText = report.LatestVersion ?? string.Empty; - PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g") ?? string.Empty; - UpdateTypeText = report.PayloadKind?.ToString() ?? string.Empty; + PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty; + UpdateTypeText = GetUpdateTypeText(report.PayloadKind); IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy; - StatusMessage = $"New version {report.LatestVersion} is available."; + StatusMessage = report.LatestVersion is null + ? GetUpdateAvailableStatusText(string.Empty) + : string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), report.LatestVersion); } else { @@ -127,71 +211,75 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable PublishedAtText = string.Empty; UpdateTypeText = string.Empty; IsDeltaUpdate = false; - StatusMessage = report.ErrorMessage ?? "You are up to date."; + StatusMessage = string.IsNullOrWhiteSpace(report.ErrorMessage) + ? GetUpToDateStatusText() + : report.ErrorMessage; } + + OnPropertyChanged(nameof(LatestVersionDisplayText)); } [RelayCommand(CanExecute = nameof(CanDownload))] private async Task DownloadAsync() { - StatusMessage = "Downloading update..."; + StatusMessage = GetDownloadingStatusText(); var result = await _orchestrator.DownloadAsync(CancellationToken.None); if (result.Success) { - StatusMessage = "Download complete. Ready to install."; + StatusMessage = GetDownloadCompleteStatusText(); } else if (result.ErrorMessage is not null && result.ErrorMessage.Contains("stale or invalid", StringComparison.OrdinalIgnoreCase)) { - StatusMessage = "Install resume state is invalid. Cancel and redownload, then retry."; + StatusMessage = GetResumeStateInvalidStatusText(); } else { - StatusMessage = result.ErrorMessage ?? "Download failed."; + StatusMessage = result.ErrorMessage ?? GetDownloadFailedStatusText(); } } [RelayCommand(CanExecute = nameof(CanInstall))] private async Task InstallAsync() { - StatusMessage = "Installing update..."; + StatusMessage = GetInstallingStatusText(); var result = await _orchestrator.InstallAsync(CancellationToken.None); if (result.Success) { - StatusMessage = "Update installed successfully."; + StatusMessage = GetInstallSuccessStatusText(); } else { - StatusMessage = result.ErrorMessage ?? result.ErrorCode ?? "Install failed."; + StatusMessage = result.ErrorMessage ?? result.ErrorCode ?? GetInstallFailedStatusText(); } } [RelayCommand(CanExecute = nameof(CanRollback))] private async Task RollbackAsync() { - StatusMessage = "Rolling back..."; + StatusMessage = GetRollingBackStatusText(); await _orchestrator.RollbackAsync(CancellationToken.None); - StatusMessage = "Rollback complete."; + StatusMessage = GetRollbackCompleteStatusText(); } [RelayCommand(CanExecute = nameof(CanPause))] private async Task PauseAsync() { await _orchestrator.PauseAsync(); - StatusMessage = "Update paused."; + StatusMessage = GetPausedStatusText(); } [RelayCommand(CanExecute = nameof(CanResume))] private async Task ResumeAsync() { - StatusMessage = "Resuming update..."; + StatusMessage = GetResumingStatusText(); var result = await _orchestrator.ResumeAsync(CancellationToken.None); if (result.Success) { - StatusMessage = "Download complete. Ready to install."; + StatusMessage = GetResumeCompleteStatusText(); } else { - StatusMessage = result.ErrorMessage ?? "Resume failed."; + StatusMessage = result.ErrorMessage ?? GetResumeFailedStatusText(); } } @@ -199,7 +287,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable private async Task CancelAsync() { await _orchestrator.CancelAsync(); - StatusMessage = "Update canceled."; + StatusMessage = GetCancelStatusText(); ProgressDetail = string.Empty; ProgressFraction = 0; } @@ -212,21 +300,31 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable private void OnOrchestratorProgressChanged(UpdateProgressReport report) { ProgressFraction = report.ProgressFraction; - StatusMessage = report.Message; + if (report.DownloadDetail is not null) { - ProgressDetail = $"{report.DownloadDetail.CurrentFile} ({report.DownloadDetail.OverallPercent}%)"; + StatusMessage = GetDownloadingStatusText(); + ProgressDetail = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.progress_download_detail_format", "{0} ({1}%)"), + report.DownloadDetail.CurrentFile, + report.DownloadDetail.OverallPercent); } else if (report.InstallDetail is not null) { + StatusMessage = GetInstallingStatusText(); ProgressDetail = report.InstallDetail.CurrentFile ?? report.InstallDetail.Message; } else { + StatusMessage = string.IsNullOrWhiteSpace(report.Message) + ? GetPhaseStatusText(CurrentPhase) + : report.Message; ProgressDetail = string.Empty; } } + private void LoadPreferenceState() { var state = _settingsFacade.Update.Get(); @@ -234,6 +332,101 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable SelectedUpdateSourceValue = state.UpdateDownloadSource; SelectedUpdateModeValue = state.UpdateMode; DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads); + + SyncComboBoxSelections(); + } + + private void SyncComboBoxSelections() + { + SelectedChannel = ChannelOptions.FirstOrDefault(o => o.Value == SelectedUpdateChannelValue) + ?? ChannelOptions.FirstOrDefault(); + SelectedSource = SourceOptions.FirstOrDefault(o => o.Value == SelectedUpdateSourceValue) + ?? SourceOptions.FirstOrDefault(); + SelectedMode = ModeOptions.FirstOrDefault(o => o.Value == SelectedUpdateModeValue) + ?? ModeOptions.FirstOrDefault(); + } + + private void RefreshLocalizedText() + { + PageTitle = L("settings.update.title", "Update"); + PageDescription = L("settings.update.description", "Check releases, choose the update channel and download source, and control how updates are installed."); + StatusSectionHeader = L("settings.update.status_section_header", "Update Status"); + CheckCardTitle = L("settings.update.check_card_title", "Check for Updates"); + StatusCardTitle = L("settings.update.status_card_title", "Update Status"); + StatusCardDescription = L("settings.update.status_card_description", "Check for updates, review release details, and continue with download or installation when a new version is available."); + ReleaseFactsTitle = L("settings.update.release_facts_title", "Release Facts"); + ReleaseFactsDescription = L("settings.update.release_facts_description", "Keep the current version, published release, and update type visible without collapsing the layout while states change."); + ProgressTitle = L("settings.update.progress_title", "Progress"); + ProgressDescription = L("settings.update.progress_description", "Watch download, installation, verification, and recovery progress here."); + ActionsTitle = L("settings.update.actions_title", "Actions"); + ActionsDescription = L("settings.update.actions_description", "The buttons below stay in place while the update phase changes, so the page does not jump around."); + PreferencesTitle = L("settings.update.preferences_title", "Update Preferences"); + PreferencesDescription = L("settings.update.preferences_description", "Choose the release channel, installer download source, installation behavior, and download parallelism."); + + CurrentVersionLabel = L("settings.update.current_version_label", "Current Version"); + LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release"); + PublishedAtLabel = L("settings.update.published_at_label", "Published At"); + LastCheckedLabel = L("settings.update.last_checked_label", "Last Checked"); + UpdateTypeLabel = L("settings.update.update_type_label", "Update Type"); + ChannelLabel = L("settings.update.channel_label", "Update Channel"); + SourceLabel = L("settings.update.source_label", "Download Source"); + ModeLabel = L("settings.update.mode_label", "Update Mode"); + DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads"); + + UpdateAvailableBadgeText = L("settings.update.badge_available", "Update available"); + PausedBadgeText = L("settings.update.badge_paused", "Paused"); + PausedHintText = L("settings.update.paused_hint", "Paused. Resume to continue from the current state."); + + CheckButtonText = L("settings.update.check_button_short", "Check"); + DownloadButtonText = L("settings.update.download_button_short", "Download"); + InstallButtonText = L("settings.update.install_button_short", "Install"); + PauseButtonText = L("settings.update.pause_button_short", "Pause"); + ResumeButtonText = L("settings.update.resume_button_short", "Resume"); + RollbackButtonText = L("settings.update.rollback_button_short", "Rollback"); + CancelButtonText = L("settings.update.cancel_button_short", "Cancel"); + + LastCheckedText = L("settings.update.last_checked_none", "Not checked yet."); + + ChannelOptions = CreateChannelOptions(); + SourceOptions = CreateSourceOptions(); + ModeOptions = CreateModeOptions(); + OnPropertyChanged(nameof(ChannelOptions)); + OnPropertyChanged(nameof(SourceOptions)); + OnPropertyChanged(nameof(ModeOptions)); + + SyncComboBoxSelections(); + + OnPropertyChanged(nameof(PhaseText)); + OnPropertyChanged(nameof(LatestVersionDisplayText)); + } + + private IReadOnlyList CreateChannelOptions() + { + return + [ + new(UpdateSettingsValues.ChannelStable, L("settings.update.channel_stable", "Stable")), + new(UpdateSettingsValues.ChannelPreview, L("settings.update.channel_preview", "Preview")) + ]; + } + + private IReadOnlyList CreateSourceOptions() + { + return + [ + new(UpdateSettingsValues.DownloadSourcePlonds, L("settings.update.source_plonds", "Plonds CDN")), + new(UpdateSettingsValues.DownloadSourceGitHub, L("settings.update.source_github", "GitHub")), + new(UpdateSettingsValues.DownloadSourceGhProxy, L("settings.update.source_gh_proxy", "GitHub Proxy")) + ]; + } + + private IReadOnlyList CreateModeOptions() + { + return + [ + new(UpdateSettingsValues.ModeManual, L("settings.update.mode_manual", "Manual")), + new(UpdateSettingsValues.ModeDownloadThenConfirm, L("settings.update.mode_confirm", "Download then Confirm")), + new(UpdateSettingsValues.ModeSilentOnExit, L("settings.update.mode_silent", "Silent on Exit")) + ]; } private void SavePreferenceState() @@ -248,6 +441,116 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable }); } + private string GetPhaseText(UpdatePhase phase) + { + return phase switch + { + UpdatePhase.Idle => L("settings.update.phase_idle", "Ready"), + UpdatePhase.Checking => L("settings.update.phase_checking", "Checking"), + UpdatePhase.Checked => L("settings.update.phase_checked", "Checked"), + UpdatePhase.Downloading => L("settings.update.phase_downloading", "Downloading"), + UpdatePhase.PausedDownloading => L("settings.update.phase_paused_download", "Paused (Download)"), + UpdatePhase.Downloaded => L("settings.update.phase_downloaded", "Downloaded"), + UpdatePhase.Installing => L("settings.update.phase_installing", "Installing"), + UpdatePhase.PausedInstalling => L("settings.update.phase_paused_install", "Paused (Install)"), + UpdatePhase.Installed => L("settings.update.phase_installed", "Installed"), + UpdatePhase.Verifying => L("settings.update.phase_verifying", "Verifying"), + UpdatePhase.Completed => L("settings.update.phase_completed", "Completed"), + UpdatePhase.Failed => L("settings.update.phase_failed", "Failed"), + UpdatePhase.Recovering => L("settings.update.phase_recovering", "Recovering"), + UpdatePhase.RollingBack => L("settings.update.phase_rolling_back", "Rolling Back"), + UpdatePhase.RolledBack => L("settings.update.phase_rolled_back", "Rolled Back"), + _ => phase.ToString() + }; + } + + + private string GetPhaseStatusText(UpdatePhase phase) + { + return phase switch + { + UpdatePhase.Checking => GetCheckingStatusText(), + UpdatePhase.Downloading => GetDownloadingStatusText(), + UpdatePhase.PausedDownloading or UpdatePhase.PausedInstalling => GetPausedStatusText(), + UpdatePhase.Installing => GetInstallingStatusText(), + UpdatePhase.Recovering => GetRecoveringStatusText(), + UpdatePhase.RollingBack => GetRollingBackStatusText(), + UpdatePhase.Completed => GetInstallSuccessStatusText(), + UpdatePhase.Installed => GetInstallSuccessStatusText(), + UpdatePhase.RolledBack => GetRollbackCompleteStatusText(), + UpdatePhase.Failed => L("settings.update.status_failed", "The update failed."), + _ => GetReadyStatusText() + }; + } + + private string GetReadyStatusText() + => L("settings.update.status_ready", "Ready to check for updates."); + + private string GetCheckingStatusText() + => L("settings.update.status_checking", "Checking GitHub releases..."); + + private string GetUpToDateStatusText() + => L("settings.update.status_up_to_date", "You are already on the latest version."); + + private string GetUpdateAvailableStatusText(string version) + => string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), version); + + private string GetDownloadingStatusText() + => L("settings.update.status_downloading", "Downloading installer..."); + + private string GetDownloadCompleteStatusText() + => L("settings.update.status_launching_installer", "Download complete. Launching installer..."); + + private string GetDownloadFailedStatusText() + => L("settings.update.status_download_failed", "Download failed."); + + private string GetResumeStateInvalidStatusText() + => L("settings.update.status_resume_state_invalid", "The resume state is invalid. Cancel and redownload, then try again."); + + private string GetInstallingStatusText() + => L("settings.update.status_installing", "Installing update..."); + + private string GetInstallSuccessStatusText() + => L("settings.update.status_installed", "Update installed successfully."); + + private string GetInstallFailedStatusText() + => L("settings.update.status_install_failed", "Install failed."); + + private string GetRollingBackStatusText() + => L("settings.update.status_rolling_back", "Rolling back..."); + + private string GetRollbackCompleteStatusText() + => L("settings.update.status_rolled_back", "Rollback complete."); + + private string GetPausedStatusText() + => L("settings.update.status_paused", "Update paused."); + + private string GetResumingStatusText() + => L("settings.update.status_resuming", "Resuming update..."); + + private string GetResumeCompleteStatusText() + => L("settings.update.status_resumed", "Resume complete."); + + private string GetResumeFailedStatusText() + => L("settings.update.status_resume_failed", "Resume failed."); + + private string GetRecoveringStatusText() + => L("settings.update.status_recovering", "Recovering installation..."); + + private string GetCancelStatusText() + => L("settings.update.status_canceled", "Update canceled."); + + private string GetUpdateTypeText(UpdatePayloadKind? payloadKind) + => payloadKind switch + { + UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy => L("settings.update.type_delta", "Incremental Update"), + UpdatePayloadKind.FullInstaller => L("settings.update.type_full", "Full Installer"), + _ => string.Empty + }; + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); + public void Dispose() { if (_disposed) diff --git a/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs index 29c621c..fdd3a37 100644 --- a/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs @@ -10,7 +10,6 @@ using CommunityToolkit.Mvvm.Input; using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.Views.Components; namespace LanMountainDesktop.ViewModels; @@ -445,22 +444,14 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase } var snapshot = result.Data; - var isNight = snapshot.Current.IsDaylight.HasValue - ? !snapshot.Current.IsDaylight.Value - : _settingsFacade.Theme.Get().IsNightMode; - var preview = XiaomiWeatherVisualResolver.Resolve( - snapshot.Current.WeatherText, - snapshot.Current.WeatherCode, - isNight, - _languageCode); - PreviewIcon = HyperOS3WeatherAssetLoader.LoadImage(preview.PrimaryIconAsset); + PreviewIcon = null; PreviewLocation = string.IsNullOrWhiteSpace(snapshot.LocationName) ? state.LocationName : snapshot.LocationName!; PreviewTemperature = snapshot.Current.TemperatureC.HasValue ? string.Format(CultureInfo.InvariantCulture, "{0:0.#}°C", snapshot.Current.TemperatureC.Value) : "--"; - PreviewCondition = preview.DisplayText; + PreviewCondition = ResolveWeatherDisplayText(snapshot.Current.WeatherText, snapshot.Current.WeatherCode); var updatedAt = (snapshot.ObservationTime ?? snapshot.FetchedAt).ToLocalTime(); PreviewUpdated = string.Format( @@ -523,18 +514,12 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase UpdateModeVisibility(); UpdateCurrentLocationSummary(); - var preview = XiaomiWeatherVisualResolver.Resolve( - "Partly cloudy", - 4, - isNight: false, - _languageCode); - SearchStatus = "2 sample locations are shown for design preview."; LocationActionStatus = "Using mocked Windows location support in design mode."; - PreviewIcon = HyperOS3WeatherAssetLoader.LoadImage(preview.PrimaryIconAsset); + PreviewIcon = null; PreviewLocation = previewLocation.Name; PreviewTemperature = "24 deg C"; - PreviewCondition = preview.DisplayText; + PreviewCondition = ResolveWeatherDisplayText("Partly cloudy", 4); PreviewUpdated = "Updated 09:42"; PreviewStatus = "Preview data is mocked for Avalonia design mode."; } @@ -713,6 +698,17 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase return _localizationService.GetString(_languageCode, key, fallback); } + private string ResolveWeatherDisplayText(string? weatherText, int? weatherCode) + { + if (!string.IsNullOrWhiteSpace(weatherText)) + { + return weatherText.Trim(); + } + + return XiaomiWeatherCodeMapper.ResolveDisplayText(weatherCode, _languageCode) + ?? L("settings.weather.preview_unknown", "Unknown"); + } + private CultureInfo ResolveCulture() { try diff --git a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml index 1593a58..596e1b5 100644 --- a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml +++ b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml @@ -6,14 +6,15 @@ BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}" BorderThickness="1" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" - Padding="14,12"> + Padding="0"> + ColumnDefinitions="Auto,*,Auto" + Margin="16,12,16,8"> + VerticalAlignment="Center"> - - + + + - + diff --git a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs index 424240d..dcf6457 100644 --- a/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs @@ -22,7 +22,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, string Name, string TimeRange, string Detail, - bool IsCurrent); + bool IsCurrent, + TimeSpan StartTime, + TimeSpan EndTime, + double Progress); private readonly DispatcherTimer _refreshTimer = new() { @@ -227,18 +230,11 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, for (var i = 0; i < _courseItems.Count; i++) { var item = _courseItems[i]; - var timeParts = item.TimeRange.Split('-'); - if (timeParts.Length != 2) continue; - - if (TimeSpan.TryParse(timeParts[0].Trim(), out var startTime) && - TimeSpan.TryParse(timeParts[1].Trim(), out var endTime)) + var shouldBeCurrent = now.TimeOfDay >= item.StartTime && now.TimeOfDay <= item.EndTime; + if (shouldBeCurrent != item.IsCurrent) { - var shouldBeCurrent = now.TimeOfDay >= startTime && now.TimeOfDay <= endTime; - if (shouldBeCurrent != item.IsCurrent) - { - needsRender = true; - break; - } + needsRender = true; + break; } } @@ -522,11 +518,22 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, var subjectName = ResolveSubjectName(snapshot, classInfo.SubjectId); var detail = ResolveSubjectDetail(snapshot, classInfo.SubjectId); var isCurrent = now.TimeOfDay >= slot.StartTime && now.TimeOfDay <= slot.EndTime; + var progress = 0.0; + if (isCurrent && slot.EndTime > slot.StartTime) + { + var elapsed = (now.TimeOfDay - slot.StartTime).TotalSeconds; + var total = (slot.EndTime - slot.StartTime).TotalSeconds; + progress = total > 0 ? Math.Clamp(elapsed / total, 0, 1) : 0; + } + result.Add(new CourseItemViewModel( Name: subjectName, TimeRange: $"{FormatTime(slot.StartTime)}-{FormatTime(slot.EndTime)}", Detail: detail, - IsCurrent: isCurrent)); + IsCurrent: isCurrent, + StartTime: slot.StartTime, + EndTime: slot.EndTime, + Progress: progress)); } return result; @@ -674,173 +681,93 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, { CourseListPanel.Children.Clear(); - var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor( - _componentColorScheme, - ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode()); - var scale = ResolveScale(); - var bulletSize = Math.Clamp(10 * scale, 5, 12); - var courseNameSize = Math.Clamp(42 * scale, 14, 42); - var secondarySize = Math.Clamp(29 * scale, 10, 28); - var lineSpacing = Math.Clamp(4 * scale, 1.5, 8); - var itemPadding = new Thickness( - Math.Clamp(6 * scale, 3, 10), - Math.Clamp(4 * scale, 2, 8), - Math.Clamp(4 * scale, 2, 8), - Math.Clamp(4 * scale, 2, 8)); - - var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821"); - var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084"); - var currentBrush = useMonetColor - ? CreateBrush("#FF4FC3F7") - : CreateBrush("#FF4D5A"); - var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2"); + var cardRadius = ComponentChromeCornerRadiusHelper.Small(); + var timeFontSize = Math.Clamp(11 * scale, 8, 14); + var courseNameFontSize = Math.Clamp(14 * scale, 10, 18); + var detailFontSize = Math.Clamp(11 * scale, 8, 14); + var progressFontSize = Math.Clamp(10 * scale, 7, 12); + var cardPadding = new Thickness( + Math.Clamp(10 * scale, 6, 14), + Math.Clamp(8 * scale, 5, 12), + Math.Clamp(10 * scale, 6, 14), + Math.Clamp(8 * scale, 5, 12)); + var timeColumnWidth = Math.Clamp(44 * scale, 30, 56); + var accentBarWidth = Math.Clamp(3 * scale, 2, 4); + var progressBarHeight = Math.Clamp(3 * scale, 2, 4); for (var i = 0; i < _courseItems.Count; i++) { var item = _courseItems[i]; - var itemControls = CreateSingleItemControl( + var itemControl = CreateTimelineItemControl( item, scale, - bulletSize, - courseNameSize, - secondarySize, - lineSpacing, - itemPadding, - primaryBrush, - secondaryBrush, - item.IsCurrent ? currentBrush : normalBulletBrush); - - CourseListPanel.Children.Add(itemControls); + cardRadius, + timeFontSize, + courseNameFontSize, + detailFontSize, + progressFontSize, + cardPadding, + timeColumnWidth, + accentBarWidth, + progressBarHeight); + CourseListPanel.Children.Add(itemControl); } } - private void IncrementalUpdateItems() - { - var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor( - _componentColorScheme, - ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode()); - - var currentBrush = useMonetColor - ? CreateBrush("#FF4FC3F7") - : CreateBrush("#FF4D5A"); - var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2"); - var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821"); - var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084"); - - for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++) - { - var item = _courseItems[i]; - var existingBorder = CourseListPanel.Children[i] as Border; - if (existingBorder == null) continue; - - var existingGrid = existingBorder.Child as Grid; - if (existingGrid == null || existingGrid.Children.Count < 2) continue; - - var bulletBorder = existingGrid.Children[0] as Border; - var textStack = existingGrid.Children[1] as StackPanel; - if (bulletBorder == null || textStack == null || textStack.Children.Count < 3) continue; - - var newBulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush; - bulletBorder.Background = newBulletBrush; - - var titleText = textStack.Children[0] as TextBlock; - var timeText = textStack.Children[1] as TextBlock; - var detailText = textStack.Children[2] as TextBlock; - - if (titleText != null) - { - if (titleText.Text != item.Name) - { - titleText.Text = item.Name; - } - titleText.Foreground = primaryBrush; - } - - if (timeText != null) - { - if (timeText.Text != item.TimeRange) - { - timeText.Text = item.TimeRange; - } - timeText.Foreground = secondaryBrush; - } - - if (detailText != null) - { - if (detailText.Text != item.Detail) - { - detailText.Text = item.Detail; - } - detailText.Foreground = secondaryBrush; - } - } - } - - private void IncrementalUpdateCurrentCourseHighlight(int currentCourseIndex) - { - var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor( - _componentColorScheme, - ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode()); - - var currentBrush = useMonetColor - ? CreateBrush("#FF4FC3F7") - : CreateBrush("#FF4D5A"); - var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2"); - - for (var i = 0; i < CourseListPanel.Children.Count; i++) - { - var border = CourseListPanel.Children[i] as Border; - if (border == null) continue; - - var grid = border.Child as Grid; - if (grid == null || grid.Children.Count < 2) continue; - - var bulletBorder = grid.Children[0] as Border; - if (bulletBorder == null) continue; - - bulletBorder.Background = i == currentCourseIndex ? currentBrush : normalBulletBrush; - } - } - - private Border CreateSingleItemControl( + private Border CreateTimelineItemControl( CourseItemViewModel item, double scale, - double bulletSize, - double courseNameSize, - double secondarySize, - double lineSpacing, - Thickness itemPadding, - IBrush primaryBrush, - IBrush secondaryBrush, - IBrush bulletBrush) + double cardRadius, + double timeFontSize, + double courseNameFontSize, + double detailFontSize, + double progressFontSize, + Thickness cardPadding, + double timeColumnWidth, + double accentBarWidth, + double progressBarHeight) { - var bullet = new Border + var subjectBrush = SubjectColorService.ResolveForegroundBrush(item.Name, _isNightVisual); + var cardBackground = SubjectColorService.ResolveBackgroundBrush(item.Name, item.IsCurrent); + var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084"); + var timeBrush = CreateBrush(_isNightVisual ? "#6B7280" : "#9AA3B2"); + var timeEndBrush = CreateBrush(_isNightVisual ? "#4B5563" : "#B8BEC9"); + + var startTimeText = new TextBlock { - Width = bulletSize, - Height = bulletSize, - CornerRadius = new CornerRadius(bulletSize * 0.5), - Background = bulletBrush, - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top, - Margin = new Thickness(0, Math.Clamp(8 * scale, 2, 12), 0, 0) + Text = FormatTime(item.StartTime), + FontSize = timeFontSize, + FontWeight = FontWeight.SemiBold, + Foreground = timeBrush, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + TextTrimming = TextTrimming.CharacterEllipsis }; - var titleText = new TextBlock + var endTimeText = new TextBlock + { + Text = FormatTime(item.EndTime), + FontSize = timeFontSize - 1, + FontWeight = FontWeight.Normal, + Foreground = timeEndBrush, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + TextTrimming = TextTrimming.CharacterEllipsis + }; + + var timeColumn = new StackPanel + { + Spacing = Math.Clamp(2 * scale, 1, 4), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + Width = timeColumnWidth, + Children = { startTimeText, endTimeText } + }; + + var courseNameText = new TextBlock { Text = item.Name, - FontSize = courseNameSize, - FontWeight = ToVariableWeight(Lerp(620, 780, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), - Foreground = primaryBrush, - TextTrimming = TextTrimming.CharacterEllipsis, - TextWrapping = TextWrapping.NoWrap - }; - - var timeText = new TextBlock - { - Text = item.TimeRange, - FontSize = secondarySize, - FontWeight = ToVariableWeight(Lerp(520, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), - Foreground = secondaryBrush, + FontSize = courseNameFontSize, + FontWeight = ToVariableWeight(Lerp(650, 800, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), + Foreground = subjectBrush, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap }; @@ -848,31 +775,129 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, var detailText = new TextBlock { Text = item.Detail, - FontSize = secondarySize, - FontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), + FontSize = detailFontSize, + FontWeight = ToVariableWeight(Lerp(450, 550, Math.Clamp((scale - 0.60) / 1.2, 0, 1))), Foreground = secondaryBrush, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap }; - var textStack = new StackPanel + var cardContent = new StackPanel { - Spacing = lineSpacing, - Children = { titleText, timeText, detailText } + Spacing = Math.Clamp(2 * scale, 1, 4) + }; + + cardContent.Children.Add(courseNameText); + cardContent.Children.Add(detailText); + + if (item.IsCurrent && item.Progress > 0) + { + var progressTrack = new Border + { + Height = progressBarHeight, + CornerRadius = new CornerRadius(progressBarHeight * 0.5), + Background = CreateBrush(_isNightVisual ? "#1AFFFFFF" : "#0D000000"), + ClipToBounds = true, + Child = new Border + { + Height = progressBarHeight, + Width = Math.Max(progressBarHeight, Math.Clamp(item.Progress * 100, 0, 100) * 0.01 * 200), + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left, + CornerRadius = new CornerRadius(progressBarHeight * 0.5), + Background = subjectBrush + } + }; + + var progressText = new TextBlock + { + Text = $"{(int)(item.Progress * 100)}%", + FontSize = progressFontSize, + FontWeight = FontWeight.SemiBold, + Foreground = subjectBrush, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right + }; + + var progressRow = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + Margin = new Thickness(0, Math.Clamp(2 * scale, 1, 4), 0, 0) + }; + progressRow.Children.Add(progressTrack); + progressRow.Children.Add(progressText); + Grid.SetColumn(progressText, 1); + + cardContent.Children.Add(progressRow); + } + + var cardInner = new Grid + { + ColumnDefinitions = new ColumnDefinitions($"{accentBarWidth},*") + }; + + if (item.IsCurrent) + { + var accentBar = new Border + { + Width = accentBarWidth, + CornerRadius = new CornerRadius(accentBarWidth * 0.5), + Background = subjectBrush, + Margin = new Thickness(0, 2, 0, 2) + }; + cardInner.Children.Add(accentBar); + + var contentWrapper = new StackPanel + { + Margin = new Thickness(Math.Clamp(6 * scale, 3, 8), 0, 0, 0), + Spacing = 0 + }; + foreach (var child in cardContent.Children.ToList()) + { + cardContent.Children.Remove(child); + contentWrapper.Children.Add(child); + } + cardInner.Children.Add(contentWrapper); + Grid.SetColumn(contentWrapper, 1); + } + else + { + var contentWrapper = new StackPanel + { + Margin = new Thickness(Math.Clamp(8 * scale, 4, 12), 0, 0, 0), + Spacing = 0 + }; + foreach (var child in cardContent.Children.ToList()) + { + cardContent.Children.Remove(child); + contentWrapper.Children.Add(child); + } + cardInner.Children.Add(contentWrapper); + Grid.SetColumn(contentWrapper, 1); + } + + var cardBorder = new Border + { + CornerRadius = new CornerRadius(cardRadius), + Background = cardBackground, + Padding = cardPadding, + Child = cardInner }; var itemGrid = new Grid { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - ColumnSpacing = Math.Clamp(10 * scale, 4, 14) + ColumnDefinitions = new ColumnDefinitions($"{timeColumnWidth},*"), + ColumnSpacing = Math.Clamp(6 * scale, 3, 10) }; - itemGrid.Children.Add(bullet); - itemGrid.Children.Add(textStack); - Grid.SetColumn(textStack, 1); + itemGrid.Children.Add(timeColumn); + itemGrid.Children.Add(cardBorder); + Grid.SetColumn(cardBorder, 1); var itemBorder = new Border { - Padding = itemPadding, + Padding = new Thickness( + Math.Clamp(10 * scale, 6, 14), + Math.Clamp(2 * scale, 1, 4), + Math.Clamp(10 * scale, 6, 14), + Math.Clamp(2 * scale, 1, 4)), Background = Brushes.Transparent, Child = itemGrid }; @@ -880,15 +905,88 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, return itemBorder; } - private int ResolveMaxVisibleItems(double scale) + private void IncrementalUpdateItems() { - var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4; - var rootVerticalPadding = RootBorder.Padding.Top + RootBorder.Padding.Bottom; - var headerEstimatedHeight = Math.Clamp(100 * scale, 54, 140); - var itemEstimatedHeight = Math.Clamp(136 * scale, 72, 178); - var available = Math.Max(1, height - rootVerticalPadding - headerEstimatedHeight); - var count = (int)Math.Floor(available / Math.Max(1, itemEstimatedHeight)); - return Math.Clamp(count, 1, 6); + for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++) + { + var item = _courseItems[i]; + var outerBorder = CourseListPanel.Children[i] as Border; + if (outerBorder == null) continue; + + var itemGrid = outerBorder.Child as Grid; + if (itemGrid == null || itemGrid.Children.Count < 2) continue; + + var cardBorder = itemGrid.Children[1] as Border; + if (cardBorder == null) continue; + + cardBorder.Background = SubjectColorService.ResolveBackgroundBrush(item.Name, item.IsCurrent); + + var cardInner = cardBorder.Child as Grid; + if (cardInner == null) continue; + + var contentPanel = cardInner.Children.OfType().FirstOrDefault(); + if (contentPanel == null) continue; + + var subjectBrush = SubjectColorService.ResolveForegroundBrush(item.Name, _isNightVisual); + var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084"); + + foreach (var child in contentPanel.Children) + { + if (child is TextBlock tb) + { + if (contentPanel.Children.IndexOf(tb) == 0) + { + if (tb.Text != item.Name) tb.Text = item.Name; + tb.Foreground = subjectBrush; + } + else if (contentPanel.Children.IndexOf(tb) == 1) + { + if (tb.Text != item.Detail) tb.Text = item.Detail; + tb.Foreground = secondaryBrush; + } + } + } + + var accentBar = cardInner.Children.OfType().FirstOrDefault(b => b.Width > 0 && b.Width < 10); + if (accentBar != null) + { + accentBar.Background = subjectBrush; + accentBar.IsVisible = item.IsCurrent; + } + } + } + + private void IncrementalUpdateCurrentCourseHighlight(int currentCourseIndex) + { + for (var i = 0; i < CourseListPanel.Children.Count; i++) + { + var outerBorder = CourseListPanel.Children[i] as Border; + if (outerBorder == null) continue; + + var itemGrid = outerBorder.Child as Grid; + if (itemGrid == null || itemGrid.Children.Count < 2) continue; + + var cardBorder = itemGrid.Children[1] as Border; + if (cardBorder == null) continue; + + var item = i < _courseItems.Count ? _courseItems[i] : null; + if (item == null) continue; + + cardBorder.Background = SubjectColorService.ResolveBackgroundBrush(item.Name, i == currentCourseIndex); + + var cardInner = cardBorder.Child as Grid; + if (cardInner == null) continue; + + var accentBar = cardInner.Children.OfType().FirstOrDefault(b => b.Width > 0 && b.Width < 10); + if (accentBar != null) + { + accentBar.IsVisible = i == currentCourseIndex; + if (i == currentCourseIndex) + { + accentBar.Background = SubjectColorService.ResolveForegroundBrush(item.Name, _isNightVisual); + } + } + } } private void ApplyAdaptiveLayout() @@ -915,38 +1013,34 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, : CreateGradientBrush("#F7F8FC", "#ECEFF6"); RootBorder.BorderBrush = CreateBrush(_isNightVisual ? "#24FFFFFF" : "#15000000"); - var rootPadding = new Thickness( + var headerPadding = new Thickness( ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24), - ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 9, 20), + ComponentChromeCornerRadiusHelper.SafeValue(12 * scale, 8, 16), ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24), - ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 8, 20)); - RootBorder.Padding = rootPadding; + ComponentChromeCornerRadiusHelper.SafeValue(8 * scale, 4, 12)); + HeaderGrid.Margin = headerPadding; - LayoutGrid.RowSpacing = Math.Clamp(14 * scale, 6, 20); - HeaderGrid.ColumnSpacing = Math.Clamp(10 * scale, 4, 16); + HeaderGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 14); DateGroup.Spacing = Math.Clamp(1.5 * scale, 0.5, 3); - MetaStack.Spacing = Math.Clamp(6 * scale, 3, 10); - CourseListPanel.Spacing = Math.Clamp(6 * scale, 3, 10); + CourseListPanel.Spacing = Math.Clamp(2 * scale, 0, 6); - var dateFontByScale = Math.Clamp(66 * scale, 26, 82); - var weekdayFontByScale = Math.Clamp(34 * scale, 13, 32); - var classCountFontByScale = Math.Clamp(40 * scale, 14, 36); + var dateFontByScale = Math.Clamp(28 * scale, 14, 36); + var weekdayFontByScale = Math.Clamp(14 * scale, 10, 18); + var classCountFontByScale = Math.Clamp(12 * scale, 9, 15); - // 宽度感知:当头部内容总需求超过可用宽度时,按比例缩小日期字体 - var availableWidth = Math.Max(1, Bounds.Width - rootPadding.Left - rootPadding.Right); + var availableWidth = Math.Max(1, Bounds.Width - headerPadding.Left - headerPadding.Right); var dateGroupEstimatedWidth = dateFontByScale * 0.6 * 3 + DateGroup.Spacing * 2; - var metaStackEstimatedWidth = classCountFontByScale * 0.6 * 4 + MetaStack.Spacing; - var headerColumnSpacing = Math.Clamp(10 * scale, 4, 16); - var totalHeaderNeed = dateGroupEstimatedWidth + headerColumnSpacing + metaStackEstimatedWidth; + var badgeEstimatedWidth = classCountFontByScale * 0.6 * 5 + 16; + var headerColumnSpacing = HeaderGrid.ColumnSpacing; + var totalHeaderNeed = dateGroupEstimatedWidth + headerColumnSpacing + badgeEstimatedWidth + weekdayFontByScale * 2; var dateFont = dateFontByScale; if (totalHeaderNeed > availableWidth) { var shrinkRatio = availableWidth / totalHeaderNeed; - dateFont = Math.Max(20, dateFontByScale * shrinkRatio); + dateFont = Math.Max(14, dateFontByScale * shrinkRatio); } - // 为 HeaderGrid 左列设置最小宽度,防止被压缩至零 var minDateColumnWidth = dateFont * 0.6 * 3 + DateGroup.Spacing * 2; HeaderGrid.ColumnDefinitions[0].MinWidth = minDateColumnWidth; @@ -958,15 +1052,24 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722"); SlashTextBlock.Foreground = slashBrush; WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463"); - ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095"); StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565"); WeekdayTextBlock.FontSize = weekdayFontByScale; - ClassCountTextBlock.FontSize = classCountFontByScale; - StatusTextBlock.FontSize = Math.Clamp(30 * scale, 12, 30); - WeekdayTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.60) / 1.2, 0, 1))); + + ClassCountTextBlock.FontSize = classCountFontByScale; ClassCountTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1))); + + var badgeBrush = useMonetColor + ? CreateBrush(_isNightVisual ? "#1A4FC3F7" : "#124FC3F7") + : CreateBrush(_isNightVisual ? "#1AFF4D5A" : "#12FF4D5A"); + ClassCountBadge.Background = badgeBrush; + ClassCountBadge.CornerRadius = new CornerRadius(ComponentChromeCornerRadiusHelper.Micro()); + ClassCountTextBlock.Foreground = useMonetColor + ? CreateBrush("#FF4FC3F7") + : CreateBrush("#FF4D5A"); + + StatusTextBlock.FontSize = Math.Clamp(14 * scale, 10, 18); } private static string FormatTime(TimeSpan time) diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 77002a0..e80bbf0 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -332,10 +332,6 @@ public sealed class DesktopComponentRuntimeRegistry BuiltInComponentIds.DesktopClock, "component.desktop_clock", () => new AnalogClockWidget()), - new DesktopComponentRuntimeRegistration( - BuiltInComponentIds.DesktopWeatherClock, - "component.weather_clock", - () => new WeatherClockWidget()), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopWorldClock, "component.world_clock", @@ -344,22 +340,6 @@ public sealed class DesktopComponentRuntimeRegistry BuiltInComponentIds.DesktopTimer, "component.desktop_timer", () => new TimerWidget()), - new DesktopComponentRuntimeRegistration( - BuiltInComponentIds.DesktopWeather, - "component.desktop_weather", - () => new WeatherWidget()), - new DesktopComponentRuntimeRegistration( - BuiltInComponentIds.DesktopHourlyWeather, - "component.hourly_weather", - () => new HourlyWeatherWidget()), - new DesktopComponentRuntimeRegistration( - BuiltInComponentIds.DesktopMultiDayWeather, - "component.multiday_weather", - () => new MultiDayWeatherWidget()), - new DesktopComponentRuntimeRegistration( - BuiltInComponentIds.DesktopExtendedWeather, - "component.extended_weather", - () => new ExtendedWeatherWidget()), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopClassSchedule, "component.class_schedule", diff --git a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml deleted file mode 100644 index 167f6e2..0000000 --- a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs deleted file mode 100644 index 290ab73..0000000 --- a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs +++ /dev/null @@ -1,1061 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Threading; -using LanMountainDesktop.ComponentSystem; -using LanMountainDesktop.Host.Abstractions; -using LanMountainDesktop.Models; -using LanMountainDesktop.PluginSdk; -using LanMountainDesktop.Services; -using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.Theme; - -namespace LanMountainDesktop.Views.Components; - -public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentChromeContextAware -{ - private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); - private static readonly IReadOnlyList SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes; - - private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromMinutes(12) }; - private readonly DispatcherTimer _animationTimer = new() { Interval = FluttermotionToken.WeatherAnimationFrameInterval }; - private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1); - private readonly TranslateTransform _backgroundMotionTranslateTransform = new(); - private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings; - private readonly LocalizationService _localizationService = new(); - - private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService; - private TimeZoneService? _timeZoneService; - private CancellationTokenSource? _refreshCts; - private double _currentCellSize = 48; - private ComponentChromeContext? _chromeContext; - private double _phase; - private bool _isAttached; - private bool _isOnActivePage = true; - private bool _isRefreshing; - private bool _autoRefreshEnabled = true; - private string _languageCode = "zh-CN"; - private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.ClearDay; - private string _componentId = BuiltInComponentIds.DesktopExtendedWeather; - private string _placementId = string.Empty; - private readonly TextBlock[] _hourlyTempBlocks; - private readonly TextBlock[] _hourlyTimeBlocks; - private readonly Image[] _hourlyIconBlocks; - private readonly TextBlock[] _dailyLabelBlocks; - private readonly TextBlock[] _dailyHighBlocks; - private readonly TextBlock[] _dailyLowBlocks; - private readonly Image[] _dailyIconBlocks; - private readonly HyperOS3WeatherVisualKind[] _dailyIconKinds; - - public ExtendedWeatherWidget() - { - InitializeComponent(); - InitializeMotionTransform(); - _hourlyTempBlocks = - [ - HourlyTemp0, HourlyTemp1, HourlyTemp2, HourlyTemp3, HourlyTemp4, HourlyTemp5 - ]; - _hourlyTimeBlocks = - [ - HourlyTime0, HourlyTime1, HourlyTime2, HourlyTime3, HourlyTime4, HourlyTime5 - ]; - _hourlyIconBlocks = - [ - HourlyIcon0, HourlyIcon1, HourlyIcon2, HourlyIcon3, HourlyIcon4, HourlyIcon5 - ]; - _dailyLabelBlocks = - [ - DailyLabel0, DailyLabel1, DailyLabel2, DailyLabel3, DailyLabel4 - ]; - _dailyHighBlocks = - [ - DailyHigh0, DailyHigh1, DailyHigh2, DailyHigh3, DailyHigh4 - ]; - _dailyLowBlocks = - [ - DailyLow0, DailyLow1, DailyLow2, DailyLow3, DailyLow4 - ]; - _dailyIconBlocks = - [ - DailyIcon0, DailyIcon1, DailyIcon2, DailyIcon3, DailyIcon4 - ]; - _dailyIconKinds = Enumerable.Repeat(HyperOS3WeatherVisualKind.Unknown, _dailyIconBlocks.Length).ToArray(); - ConfigureTextOverflowGuards(); - _refreshTimer.Tick += OnRefreshTimerTick; - _animationTimer.Tick += OnAnimationTick; - AttachedToVisualTree += OnAttachedToVisualTree; - DetachedFromVisualTree += OnDetachedFromVisualTree; - SizeChanged += OnSizeChanged; - ApplyCellSize(_currentCellSize); - ApplyVisualTheme(_activeVisualKind); - ApplyFallback(); - ApplyAutoRefreshSettings(); - } - - private void ConfigureTextOverflowGuards() - { - CityTextBlock.TextWrapping = TextWrapping.NoWrap; - CityTextBlock.TextTrimming = TextTrimming.CharacterEllipsis; - CityTextBlock.MaxLines = 1; - - ConditionTextBlock.TextWrapping = TextWrapping.NoWrap; - ConditionTextBlock.TextTrimming = TextTrimming.CharacterEllipsis; - ConditionTextBlock.MaxLines = 1; - - RangeTextBlock.TextWrapping = TextWrapping.NoWrap; - RangeTextBlock.TextTrimming = TextTrimming.CharacterEllipsis; - RangeTextBlock.MaxLines = 1; - - TemperatureTextBlock.TextWrapping = TextWrapping.NoWrap; - TemperatureTextBlock.TextTrimming = TextTrimming.None; - TemperatureTextBlock.MaxLines = 1; - } - - public void ApplyCellSize(double cellSize) - { - _currentCellSize = Math.Max(1, cellSize); - var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Extended4x4); - var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4; - var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4; - var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius(_chromeContext); - ComponentChromeCornerRadiusHelper.Apply( - mainRectangleCornerRadius, - RootBorder, - BackgroundImageLayer, - BackgroundMotionLayer, - BackgroundTintLayer, - BackgroundLightLayer, - BackgroundShadeLayer); - var horizontalPadding = Math.Clamp(Math.Min(width * metrics.HorizontalPaddingScale * 0.30, width * 0.11), 4, 34); - var verticalPadding = Math.Clamp(Math.Min(height * metrics.VerticalPaddingScale * 0.30, height * 0.11), 4, 34); - ContentPaddingBorder.Padding = new Thickness( - ComponentChromeCornerRadiusHelper.SafeValue(horizontalPadding, 4, 34, _chromeContext), - ComponentChromeCornerRadiusHelper.SafeValue(verticalPadding, 4, 34, _chromeContext)); - ApplyTypography(width, height); - } - - public void SetComponentChromeContext(ComponentChromeContext context) - { - ArgumentNullException.ThrowIfNull(context); - _chromeContext = context; - ApplyCellSize(_currentCellSize); - } - - public void SetTimeZoneService(TimeZoneService timeZoneService) - { - if (_timeZoneService is not null) - { - _timeZoneService.TimeZoneChanged -= OnTimeZoneChanged; - } - - _timeZoneService = timeZoneService; - _timeZoneService.TimeZoneChanged += OnTimeZoneChanged; - } - - public void ClearTimeZoneService() - { - if (_timeZoneService is null) - { - return; - } - - _timeZoneService.TimeZoneChanged -= OnTimeZoneChanged; - _timeZoneService = null; - } - - public void SetWeatherInfoService(IWeatherInfoService weatherInfoService) - { - _weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService; - if (_isAttached && _isOnActivePage) - { - _ = RefreshWeatherAsync(false); - } - } - - public void RefreshFromSettings() - { - ApplyAutoRefreshSettings(); - if (_isAttached && _isOnActivePage) - { - _ = RefreshWeatherAsync(forceRefresh: true); - } - } - - public void SetComponentPlacementContext(string componentId, string? placementId) - { - _componentId = string.IsNullOrWhiteSpace(componentId) - ? BuiltInComponentIds.DesktopExtendedWeather - : componentId.Trim(); - _placementId = placementId?.Trim() ?? string.Empty; - RefreshFromSettings(); - } - - public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) - { - _ = isEditMode; - var wasOnActivePage = _isOnActivePage; - _isOnActivePage = isOnActivePage; - UpdateTimerState(); - - if (!wasOnActivePage && _isOnActivePage && _isAttached) - { - _ = RefreshWeatherAsync(false); - } - } - - private void OnTimeZoneChanged(object? sender, EventArgs e) - { - if (_isAttached && _isOnActivePage) - { - _ = RefreshWeatherAsync(false); - } - } - - private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) - { - _isAttached = true; - ApplyAutoRefreshSettings(); - UpdateTimerState(); - if (_isOnActivePage) - { - _ = RefreshWeatherAsync(false); - } - } - - private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) - { - _isAttached = false; - UpdateTimerState(); - CancelRefresh(); - } - - private void OnSizeChanged(object? sender, SizeChangedEventArgs e) - { - ApplyCellSize(_currentCellSize); - } - - private async void OnRefreshTimerTick(object? sender, EventArgs e) - { - await RefreshWeatherAsync(false); - } - - private void OnAnimationTick(object? sender, EventArgs e) - { - if (!_isAttached || !_isOnActivePage) - { - return; - } - - _phase += 0.018; - if (_phase > Math.PI * 2) _phase -= Math.PI * 2; - var sin = Math.Sin(_phase); - var cos = Math.Cos(_phase * 0.83); - SetMotionTransform(sin * 7.0, cos * 5.0, 1.05 + (sin * 0.01)); - BackgroundMotionLayer.Opacity = Math.Clamp(0.27 + (cos * 0.05), 0.10, 0.90); - BackgroundLightLayer.Opacity = Math.Clamp(0.62 + (sin * 0.06), 0.20, 0.95); - BackgroundShadeLayer.Opacity = Math.Clamp(0.80 + (cos * 0.03), 0.45, 0.95); - } - - private async Task RefreshWeatherAsync(bool forceRefresh) - { - if (!_isAttached || !_isOnActivePage || _isRefreshing) - { - return; - } - - _isRefreshing = true; - var app = _settingsService.LoadSnapshot(SettingsScope.App); - _languageCode = _localizationService.NormalizeLanguageCode(app.LanguageCode); - var locale = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) ? "zh_cn" : "en_us"; - var latitude = double.IsFinite(app.WeatherLatitude) ? Math.Clamp(app.WeatherLatitude, -90, 90) : 39.9042; - var longitude = double.IsFinite(app.WeatherLongitude) ? Math.Clamp(app.WeatherLongitude, -180, 180) : 116.4074; - var locationKey = (app.WeatherLocationKey ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(locationKey) && string.Equals(app.WeatherLocationMode, "Coordinates", StringComparison.OrdinalIgnoreCase)) - { - locationKey = string.Create(CultureInfo.InvariantCulture, $"coord:{latitude:F4},{longitude:F4}"); - } - - if (string.IsNullOrWhiteSpace(locationKey)) - { - ApplyFallback(); - _isRefreshing = false; - return; - } - - SetLoadingSkeleton(true); - - var cts = new CancellationTokenSource(); - var previous = Interlocked.Exchange(ref _refreshCts, cts); - previous?.Cancel(); - previous?.Dispose(); - - try - { - var query = new WeatherQuery(locationKey, latitude, longitude, 7, locale, ForceRefresh: forceRefresh); - var result = await _weatherInfoService.GetWeatherAsync(query, cts.Token); - if (cts.IsCancellationRequested || !_isAttached) - { - return; - } - - if (!result.Success || result.Data is null) - { - ApplyFallback(); - return; - } - - ApplySnapshot(result.Data, app.WeatherLocationName); - } - catch (OperationCanceledException) - { - // Ignore canceled requests. - } - catch - { - ApplyFallback(); - } - finally - { - if (ReferenceEquals(_refreshCts, cts)) - { - _refreshCts = null; - } - - cts.Dispose(); - _isRefreshing = false; - } - } - - private void ApplySnapshot(WeatherSnapshot snapshot, string? fallbackLocationName) - { - var isNight = HyperOS3WeatherTheme.ResolveIsNightPreferred( - snapshot, - _timeZoneService?.CurrentTimeZone, - _timeZoneService?.GetCurrentTime() ?? DateTime.Now); - var currentVisual = XiaomiWeatherVisualResolver.Resolve( - snapshot.Current.WeatherText, - snapshot.Current.WeatherCode, - isNight, - _languageCode); - var kind = currentVisual.VisualKind; - ApplyVisualTheme(kind); - SetLoadingSkeleton(false); - WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(currentVisual.PrimaryIconAsset); - CityTextBlock.Text = ResolveLocation(snapshot.LocationName, fallbackLocationName); - ConditionTextBlock.Text = currentVisual.DisplayText; - TemperatureTextBlock.Text = FormatTemperature(snapshot.Current.TemperatureC); - - var today = snapshot.DailyForecasts.FirstOrDefault(); - RangeTextBlock.Text = $"{FormatTemperature(today?.HighTemperatureC)}/{FormatTemperature(today?.LowTemperatureC)}"; - - var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now; - var timelineStart = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind); - var sunsetSlotIndex = ResolveSunsetSlotIndex(snapshot, timelineStart, _hourlyTempBlocks.Length); - var localHourly = snapshot.HourlyForecasts - .Select(item => new { Source = item, Time = ConvertToConfiguredTime(item.Time) }) - .OrderBy(item => item.Time) - .ToList(); - - for (var i = 0; i < _hourlyTempBlocks.Length; i++) - { - var target = timelineStart.AddHours(i); - var item = localHourly - .OrderBy(entry => Math.Abs((entry.Time - target).TotalMinutes)) - .FirstOrDefault(); - var weatherCode = item?.Source.WeatherCode ?? snapshot.Current.WeatherCode; - var hourVisual = XiaomiWeatherVisualResolver.Resolve( - item?.Source.WeatherText, - weatherCode, - IsNightHour(target), - _languageCode); - var hourKind = hourVisual.VisualKind; - _hourlyTempBlocks[i].Text = i == sunsetSlotIndex - ? L("weather.hourly.sunset", "Sunset") - : FormatTemperature(item?.Source.TemperatureC ?? snapshot.Current.TemperatureC); - _hourlyTimeBlocks[i].Text = target.ToString("HH:mm", CultureInfo.InvariantCulture); - _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(hourVisual.CompactIconAsset); - } - - var todayDate = DateOnly.FromDateTime(now); - for (var i = 0; i < _dailyLabelBlocks.Length; i++) - { - var date = todayDate.AddDays(i + 1); - var daily = snapshot.DailyForecasts.FirstOrDefault(entry => entry.Date == date) ?? snapshot.DailyForecasts.FirstOrDefault(); - var weatherCode = daily?.DayWeatherCode ?? daily?.NightWeatherCode ?? snapshot.Current.WeatherCode; - var dayVisual = XiaomiWeatherVisualResolver.Resolve( - daily?.DayWeatherText ?? daily?.NightWeatherText, - weatherCode, - false, - _languageCode); - var dayKind = dayVisual.VisualKind; - var dayText = dayVisual.DisplayText; - _dailyLabelBlocks[i].Text = $"{ResolveDayLabel(date, i + 1)}·{dayText}"; - _dailyHighBlocks[i].Text = FormatTemperatureValue(daily?.HighTemperatureC); - _dailyLowBlocks[i].Text = FormatTemperatureValue(daily?.LowTemperatureC); - _dailyIconKinds[i] = dayKind; - _dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(dayVisual.CompactIconAsset); - } - } - - private void ApplyFallback() - { - ApplyVisualTheme(HyperOS3WeatherVisualKind.Unknown); - SetLoadingSkeleton(false); - WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(HyperOS3WeatherVisualKind.Unknown)); - CityTextBlock.Text = L("weather.widget.location_unknown", "Unknown location"); - ConditionTextBlock.Text = L("weather.widget.loading", "Loading..."); - TemperatureTextBlock.Text = "--°"; - RangeTextBlock.Text = "--°/--°"; - var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now; - var timelineStart = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind); - for (var i = 0; i < _hourlyTempBlocks.Length; i++) - { - _hourlyTempBlocks[i].Text = i == 3 ? L("weather.hourly.sunset", "Sunset") : "--°"; - _hourlyTimeBlocks[i].Text = timelineStart.AddHours(i).ToString("HH:mm", CultureInfo.InvariantCulture); - _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(HyperOS3WeatherVisualKind.Unknown)); - } - - for (var i = 0; i < _dailyLabelBlocks.Length; i++) - { - _dailyLabelBlocks[i].Text = $"{ResolveDayLabel(DateOnly.FromDateTime(DateTime.Now).AddDays(i + 1), i + 1)}·{L("weather.widget.condition_cloudy", "Cloudy")}"; - _dailyHighBlocks[i].Text = "--"; - _dailyLowBlocks[i].Text = "--"; - _dailyIconKinds[i] = HyperOS3WeatherVisualKind.Unknown; - _dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(HyperOS3WeatherVisualKind.Unknown)); - } - } - - private void ApplyVisualTheme(HyperOS3WeatherVisualKind kind) - { - _activeVisualKind = kind; - var palette = HyperOS3WeatherTheme.ResolvePalette(kind); - RootBorder.Background = CreateGradientBrush(palette.GradientFrom, palette.GradientTo); - - var background = CreateImageBrush(HyperOS3WeatherTheme.ResolveBackgroundAsset(kind)); - BackgroundImageLayer.Background = background ?? CreateGradientBrush(palette.GradientFrom, palette.GradientTo); - BackgroundMotionLayer.Background = background ?? CreateGradientBrush(palette.GradientFrom, palette.GradientTo); - BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint); - - var isNightVisual = kind is HyperOS3WeatherVisualKind.ClearNight or HyperOS3WeatherVisualKind.PartlyCloudyNight or HyperOS3WeatherVisualKind.CloudyNight; - var backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples( - palette.GradientFrom, - palette.GradientTo, - palette.Tint, - isNightVisual); - TemperatureTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush( - palette.PrimaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagLargeTextContrast); - CityTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush( - palette.SecondaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagNormalTextContrast, - isNightVisual ? (byte)0xE6 : (byte)0xD4); - ConditionTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush( - palette.PrimaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagLargeTextContrast, - isNightVisual ? (byte)0xED : (byte)0xDF); - RangeTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush( - palette.PrimaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagLargeTextContrast, - isNightVisual ? (byte)0xE2 : (byte)0xCE); - HourlyPanelBorder.Background = Brushes.Transparent; - SeparatorLine.Background = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0x3A : (byte)0x28); - - var hourlyTempBrush = WeatherTypographyAccessibility.CreateReadableBrush( - palette.PrimaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagNormalTextContrast, - isNightVisual ? (byte)0xEE : (byte)0xE1); - var hourlyTimeBrush = WeatherTypographyAccessibility.CreateReadableBrush( - palette.TertiaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagNormalTextContrast, - isNightVisual ? (byte)0xC8 : (byte)0xAC); - var dailyTextBrush = WeatherTypographyAccessibility.CreateReadableBrush( - palette.PrimaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagNormalTextContrast, - isNightVisual ? (byte)0xEA : (byte)0xDF); - var dailyLowBrush = WeatherTypographyAccessibility.CreateReadableBrush( - palette.TertiaryText, - backgroundSamples, - WeatherTypographyAccessibility.WcagNormalTextContrast, - isNightVisual ? (byte)0xBE : (byte)0xA6); - for (var i = 0; i < _hourlyTempBlocks.Length; i++) - { - _hourlyTempBlocks[i].Foreground = hourlyTempBrush; - _hourlyTimeBlocks[i].Foreground = hourlyTimeBrush; - } - - for (var i = 0; i < _dailyLabelBlocks.Length; i++) - { - _dailyLabelBlocks[i].Foreground = dailyTextBrush; - _dailyHighBlocks[i].Foreground = dailyTextBrush; - _dailyLowBlocks[i].Foreground = dailyLowBrush; - } - } - - private void ApplyTypography(double width, double height) - { - var innerWidth = Math.Max(140, width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right); - var innerHeight = Math.Max(140, height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom); - var fitScale = Math.Clamp(Math.Min(innerWidth / 592d, innerHeight / 600d), 0.30, 3.20); - var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.80); - var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.80); - var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1); - - LayoutRoot.RowSpacing = Math.Clamp(8 * fitScale, 1, 22); - SummaryGrid.ColumnSpacing = Math.Clamp(16 * fitScale, 4, 38); - SummaryInfoGrid.RowSpacing = Math.Clamp(2 * fitScale, 0.2, 9); - BottomInfoStack.Spacing = Math.Clamp(2 * fitScale, 0.3, 14); - ConditionRangeStack.Spacing = Math.Clamp(9 * fitScale, 1, 24); - HourlyGrid.ColumnSpacing = Math.Clamp(4 * fitScale, 0.5, 22); - - var summaryHeight = Math.Clamp(innerHeight * 0.22, 34, Math.Max(34, innerHeight * 0.42)); - var hourlyHeight = Math.Clamp(innerHeight * 0.20, 34, Math.Max(34, innerHeight * 0.36)); - var separatorBandHeight = Math.Clamp(innerHeight * 0.03, 4, 40); - if (LayoutRoot.RowDefinitions.Count >= 4) - { - LayoutRoot.RowDefinitions[0].Height = new GridLength(summaryHeight, GridUnitType.Pixel); - LayoutRoot.RowDefinitions[1].Height = new GridLength(hourlyHeight, GridUnitType.Pixel); - LayoutRoot.RowDefinitions[2].Height = new GridLength(separatorBandHeight, GridUnitType.Pixel); - LayoutRoot.RowDefinitions[3].Height = new GridLength(1, GridUnitType.Star); - } - - var topScale = Math.Clamp(((summaryHeight / 118d) * 0.44) + (visualScale * 0.84), 0.24, 4.00); - var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1); - var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind); - var iconSize = Math.Clamp(Lerp(90, 122, iconGrowth) * topScale * iconScaleBoost, 14, 360); - iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.18, 0.26, iconGrowth))); - var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text) - ? "00°" - : TemperatureTextBlock.Text.Trim(); - var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6); - var temperatureMaxWidth = Math.Max(30, innerWidth - iconSize - SummaryGrid.ColumnSpacing - 6); - var rawTemperatureSize = Math.Clamp(Lerp(72, 102, iconGrowth) * topScale, 14, 340); - var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62); - TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 10, 340); - TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 380, emphasis)); - TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 30, Math.Max(300, innerWidth * 0.66)); - TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -12, 0), 0, 0); - - var cityFontSize = Math.Clamp(18.5 * topScale, 7, 86); - var conditionFontSize = Math.Clamp(20 * topScale, 7, 90); - var rangeFontSize = Math.Clamp(20 * topScale, 7, 90); - CityTextBlock.FontSize = cityFontSize; - ConditionTextBlock.FontSize = conditionFontSize; - RangeTextBlock.FontSize = rangeFontSize; - CityTextBlock.FontWeight = ToVariableWeight(Lerp(530, 620, emphasis)); - ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(580, 660, emphasis)); - RangeTextBlock.FontWeight = ToVariableWeight(Lerp(600, 680, emphasis)); - CityTextBlock.LineHeight = cityFontSize * 1.08; - ConditionTextBlock.LineHeight = conditionFontSize * 1.06; - RangeTextBlock.LineHeight = rangeFontSize * 1.06; - - WeatherIconImage.Width = iconSize; - WeatherIconImage.Height = iconSize; - WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.4 * topScale, -12, 0), 0, 0); - ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.25, 28, 340); - RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.31, 34, 380); - CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 34, 420); - - HourlyPanelBorder.Padding = new Thickness(0); - HourlyPanelBorder.CornerRadius = new CornerRadius(0); - HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(4 * fitScale, 0, 18), 0, 0); - - var hourlyCellWidth = Math.Max(12, (innerWidth - (HourlyGrid.ColumnSpacing * 5)) / 6d); - var hourlyCellScale = Math.Clamp( - Math.Min((visualScale * 0.44) + ((hourlyHeight / 120d) * 0.62), hourlyCellWidth / 76d), - 0.22, - 3.80); - var hourlyTempSize = Math.Clamp(19 * hourlyCellScale, 6, 72); - var hourlyTimeSize = Math.Clamp(14 * hourlyCellScale, 6, 52); - var hourlyIconSize = Math.Clamp(42 * hourlyCellScale, 9, 140); - hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyCellWidth * 0.86)); - hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyHeight * 0.56)); - var hourlyStackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10); - for (var i = 0; i < _hourlyTempBlocks.Length; i++) - { - _hourlyTempBlocks[i].FontSize = hourlyTempSize; - _hourlyTimeBlocks[i].FontSize = hourlyTimeSize; - _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(540, 650, emphasis)); - _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(450, 560, emphasis)); - _hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260); - _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260); - _hourlyIconBlocks[i].Width = hourlyIconSize; - _hourlyIconBlocks[i].Height = hourlyIconSize; - if (_hourlyTempBlocks[i].Parent is StackPanel stack) stack.Spacing = hourlyStackSpacing; - } - - SeparatorLine.Margin = new Thickness(0, Math.Clamp(separatorBandHeight * 0.45, 1, 16), 0, 0); - DailyGrid.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 0.5, 24), 0, 0); - var dailyAreaHeight = Math.Max(50, innerHeight - summaryHeight - hourlyHeight - separatorBandHeight - (LayoutRoot.RowSpacing * 3) - DailyGrid.Margin.Top); - var dailyRowSpacing = Math.Clamp(dailyAreaHeight * 0.028, 1, 22); - DailyGrid.RowSpacing = dailyRowSpacing; - var dailyRowHeight = Math.Max(8, (dailyAreaHeight - (dailyRowSpacing * 4)) / 5d); - var dailyRowScale = Math.Clamp(((dailyRowHeight / 40d) * 0.62) + (visualScale * 0.44), 0.22, 3.80); - - var dailyLabelSize = Math.Clamp(18.5 * dailyRowScale, 6, 70); - var dailyTempSize = Math.Clamp(19 * dailyRowScale, 6, 72); - var dailyIconSize = Math.Clamp(43 * dailyRowScale, 9, 132); - dailyIconSize = Math.Min(dailyIconSize, Math.Max(10, dailyRowHeight * 0.92)); - dailyIconSize = Math.Min(dailyIconSize, Math.Max(10, innerWidth * 0.14)); - var dailyLabelMaxWidth = Math.Clamp(innerWidth * 0.52, 28, 460); - var dailyHighWidth = Math.Clamp(innerWidth * 0.14, 14, 140); - var dailyLowWidth = Math.Clamp(innerWidth * 0.11, 12, 120); - var dailyHighRightGap = Math.Clamp(innerWidth * 0.018, 1, 28); - for (var i = 0; i < _dailyLabelBlocks.Length; i++) - { - _dailyLabelBlocks[i].FontSize = dailyLabelSize; - _dailyHighBlocks[i].FontSize = dailyTempSize; - _dailyLowBlocks[i].FontSize = dailyTempSize; - _dailyLabelBlocks[i].FontWeight = ToVariableWeight(Lerp(520, 620, emphasis)); - _dailyHighBlocks[i].FontWeight = ToVariableWeight(Lerp(560, 680, emphasis)); - _dailyLowBlocks[i].FontWeight = ToVariableWeight(Lerp(470, 590, emphasis)); - _dailyLabelBlocks[i].MaxWidth = dailyLabelMaxWidth; - _dailyHighBlocks[i].Width = dailyHighWidth; - _dailyLowBlocks[i].Width = dailyLowWidth; - _dailyHighBlocks[i].Margin = new Thickness(0, 0, dailyHighRightGap, 0); - _dailyLowBlocks[i].Margin = new Thickness(0); - _dailyHighBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right; - _dailyLowBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right; - _dailyHighBlocks[i].TextAlignment = TextAlignment.Right; - _dailyLowBlocks[i].TextAlignment = TextAlignment.Right; - if (_dailyIconBlocks[i].Parent is Grid dailyRowGrid) - { - dailyRowGrid.ColumnSpacing = Math.Clamp(9 * dailyRowScale, 4, 18); - } - - var dailyKind = i < _dailyIconKinds.Length - ? _dailyIconKinds[i] - : HyperOS3WeatherVisualKind.Unknown; - var dailyIconVisualSize = Math.Clamp( - dailyIconSize * ResolveDailyMiniIconScaleBoost(dailyKind), - 8, - 148); - dailyIconVisualSize = Math.Min(dailyIconVisualSize, Math.Max(10, dailyRowHeight * 0.94)); - _dailyIconBlocks[i].Width = dailyIconVisualSize; - _dailyIconBlocks[i].Height = dailyIconVisualSize; - } - } - - private int ResolveSunsetSlotIndex(WeatherSnapshot snapshot, DateTime startTime, int slotCount) - { - if (slotCount <= 0) - { - return -1; - } - - var todayForecast = snapshot.DailyForecasts.FirstOrDefault(item => item.Date == DateOnly.FromDateTime(startTime)); - if (todayForecast is null || !TryParseClockTime(todayForecast.SunsetTime, out var sunsetClock)) - { - return -1; - } - - var sunsetTime = startTime.Date + sunsetClock; - var bestIndex = -1; - var bestDelta = double.MaxValue; - for (var i = 0; i < slotCount; i++) - { - var slotTime = startTime.AddHours(i); - var deltaMinutes = Math.Abs((slotTime - sunsetTime).TotalMinutes); - if (deltaMinutes >= bestDelta) - { - continue; - } - - bestDelta = deltaMinutes; - bestIndex = i; - } - - return bestDelta <= 60 ? bestIndex : -1; - } - - private static bool TryParseClockTime(string? text, out TimeSpan value) - { - if (string.IsNullOrWhiteSpace(text)) - { - value = default; - return false; - } - - var candidate = text.Trim(); - if (TimeSpan.TryParse(candidate, CultureInfo.InvariantCulture, out value)) - { - return true; - } - - if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dto)) - { - value = dto.TimeOfDay; - return true; - } - - if (DateTime.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt)) - { - value = dt.TimeOfDay; - return true; - } - - return false; - } - - private static bool IsNightHour(DateTime time) => time.Hour < 6 || time.Hour >= 18; - - private string ResolveDayLabel(DateOnly date, int offset) - { - if (offset == 1) return L("weather.multiday.tomorrow", "Tomorrow"); - var dt = date.ToDateTime(TimeOnly.MinValue); - if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)) - { - return dt.ToString("ddd", CultureInfo.GetCultureInfo("zh-CN")) - .Replace("星期", "周", StringComparison.Ordinal); - } - - return dt.ToString("ddd", CultureInfo.InvariantCulture); - } - - private string ResolveLocation(string? rawLocation, string? fallbackLocation) - { - var input = string.IsNullOrWhiteSpace(rawLocation) ? fallbackLocation : rawLocation; - return ResolvePreciseDisplayLocation( - input, - _languageCode, - L("weather.widget.location_unknown", "Unknown location")); - } - - private static string ResolvePreciseDisplayLocation(string? rawName, string languageCode, string fallback) - { - if (string.IsNullOrWhiteSpace(rawName)) - { - return fallback; - } - - var name = rawName.Trim(); - if (name.Length == 0) - { - return fallback; - } - - var isZh = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase); - var candidates = new List { name }; - - // Prefer detailed parts inside parenthesis, e.g. "Beijing (Haidian)". - var parenthesisMatches = Regex.Matches(name, @"\(([^()]+)\)|\uFF08([^\uFF08\uFF09]+)\uFF09"); - foreach (Match match in parenthesisMatches) - { - var inner = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; - if (!string.IsNullOrWhiteSpace(inner)) - { - candidates.Add(inner.Trim()); - } - } - - var nameWithoutParenthesis = Regex.Replace(name, @"\([^()]*\)|\uFF08[^\uFF08\uFF09]*\uFF09", " "); - candidates.Add(nameWithoutParenthesis); - - const string splitPattern = @"[\s\|/\\,\uFF0C\u3001\u00B7]+"; - foreach (var piece in Regex.Split(string.Join(" ", candidates), splitPattern)) - { - var token = piece.Trim(); - if (!string.IsNullOrWhiteSpace(token)) - { - candidates.Add(token); - } - } - - var best = fallback; - var bestScore = int.MinValue; - foreach (var candidate in candidates - .Select(c => c.Trim()) - .Where(c => !string.IsNullOrWhiteSpace(c)) - .Distinct(StringComparer.OrdinalIgnoreCase)) - { - var score = ScoreLocationToken(candidate, isZh); - if (score > bestScore) - { - bestScore = score; - best = candidate; - } - } - - return string.IsNullOrWhiteSpace(best) ? fallback : best; - } - - private static int ScoreLocationToken(string token, bool isZh) Internal Server Error - MistAperio Gitea 500 Internal Server Error Gitea Version: 1.25.5
Gitea Version: 1.25.5