changed.更了好多

This commit is contained in:
lincube
2026-05-12 16:46:49 +08:00
parent 563f12caa1
commit 33c264f6dd
127 changed files with 5257 additions and 10534 deletions

1
.gitignore vendored
View File

@@ -518,3 +518,4 @@ nul
/velopack-output-local-verify
/velopack-output-local
/test-aot-publish
/.claude/worktrees

View File

@@ -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
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="0">
<Grid x:Name="LayoutGrid"
RowDefinitions="Auto,*">
<Grid x:Name="HeaderGrid"
ColumnDefinitions="Auto,*,Auto"
Padding="16,12,16,8">
<StackPanel x:Name="DateGroup"
Orientation="Horizontal"
VerticalAlignment="Center">
<TextBlock x:Name="MonthTextBlock"
FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="SlashTextBlock"
Text="/"
FontWeight="Bold" />
<TextBlock x:Name="DayTextBlock"
FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock x:Name="WeekdayTextBlock"
Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<Border x:Name="ClassCountBadge"
Grid.Column="2"
VerticalAlignment="Center"
Padding="8,3"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}">
<TextBlock x:Name="ClassCountTextBlock"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
<ScrollViewer x:Name="ContentScrollViewer"
Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="CourseListPanel"
Spacing="4" />
</ScrollViewer>
<TextBlock x:Name="StatusTextBlock"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
IsVisible="False"
TextWrapping="Wrap" />
</Grid>
</Border>
</UserControl>
```
---
### 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. 测试通过

View File

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

View File

@@ -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<SelectionOption>` 类型的数据源属性
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 选择能正确保存和加载

View File

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

View File

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

View File

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

View File

@@ -5,5 +5,8 @@
RequestedThemeVariant="Default">
<Application.Styles>
<sty:FluentAvaloniaTheme />
<Style Selector="Window">
<Setter Property="Topmost" Value="True" />
</Style>
</Application.Styles>
</Application>

View File

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

View File

@@ -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<string>(out var text) &&
Enum.TryParse<MultiInstanceLaunchBehavior>(text, ignoreCase: true, out var parsed))
{
return parsed;
}
if (value.TryGetValue<int>(out var numeric) &&
Enum.IsDefined(typeof(MultiInstanceLaunchBehavior), numeric))
{
return (MultiInstanceLaunchBehavior)numeric;
}
}
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
}
public readonly record struct HostAppSettingsStartupDefaults(

View File

@@ -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;
/// <summary>
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private readonly Action<StartupProgressMessage> _onProgress;
private Task? _listenTask;
private NamedPipeServerStream? _currentPipe;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// 这在 Windows Message 模式和 unix Byte 模式下均能可靠工作。
/// </summary>
private const int LengthPrefixSize = 4;
private const int BackoffBaseMs = 200;
private const int BackoffMaxMs = 5000;
private const int BackoffJitterMs = 100;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
/// <summary>
/// 启动 IPC 服务端监听
/// </summary>
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;
}
/// <summary>
/// 从已连接的管道中持续读取消息,直到连接断开或取消
/// </summary>
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
{
var lengthBuffer = ArrayPool<byte>.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<byte>.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<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
/// <summary>
/// 停止 IPC 服务端
/// </summary>
public void Stop()
{
_cts.Cancel();
try
{
_currentPipe?.Dispose();
}
catch { }
}
public void Dispose()
{
Stop();
_cts.Dispose();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch { }
}
}

View File

@@ -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<string, string>(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<string, string>(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<LauncherResult?> 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<PublicShellStatus?> TryGetExistingHostStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
{
try
{
var connected = ipcClient.IsConnected ||
await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
if (!connected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
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<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
LanMountainDesktopIpcClient ipcClient,
MultiInstanceLaunchBehavior behavior,
PublicShellStatus status)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
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<ExistingHostBehaviorResult> 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<ExistingHostBehaviorResult> 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<ExistingHostBehaviorResult> 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<MultiInstancePromptResult> 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<PublicShellActivationResult?> 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<PublicShellStatus?> 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,

View File

@@ -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">
<Design.DataContext>
<views:ErrorWindow />
@@ -20,79 +24,128 @@
<Grid RowDefinitions="*,Auto">
<Grid Grid.Row="0"
Margin="24"
Margin="28,24,28,20"
RowDefinitions="Auto,Auto,*"
ColumnDefinitions="Auto,*">
<Border x:Name="ErrorIconBorder"
Grid.Column="0"
Width="52"
Height="52"
Margin="0,4,18,0"
Background="#2B161A"
CornerRadius="26"
Width="56"
Height="56"
Margin="0,0,18,0"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="28"
VerticalAlignment="Top">
<TextBlock Text="!"
FontSize="24"
FontWeight="Bold"
Foreground="#FFB4AB"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<fi:SymbolIcon Symbol="ErrorCircle"
IconVariant="Regular"
FontSize="28"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="10">
Spacing="8">
<TextBlock x:Name="TitleText"
Text="Launcher could not confirm startup"
FontSize="20"
FontSize="22"
FontWeight="SemiBold"
Foreground="#F6F7FB"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap" />
<TextBlock x:Name="ErrorMessageText"
Text="LanMountain Desktop did not reach the expected startup state."
FontSize="14"
Foreground="#D2D7E1"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="22" />
<TextBlock x:Name="SuggestionText"
Text="You can inspect logs, retry when the old process is gone, or reactivate the current instance."
FontSize="13"
Foreground="#9BA5B7"
TextWrapping="Wrap"
LineHeight="20" />
</StackPanel>
<ui:FAInfoBar x:Name="SuggestionInfoBar"
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,20,0,14"
IsOpen="True"
IsClosable="False"
Severity="Warning"
Title="Startup recovery"
Message="You can inspect logs, wait for the current process, or activate the running desktop instance.">
<ui:FAInfoBar.IconSource>
<ui:FAFontIconSource Glyph="&#xF0288;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FAInfoBar.IconSource>
</ui:FAInfoBar>
<Expander Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2"
Header="Diagnostic details"
IsExpanded="True">
<TextBox x:Name="ErrorDetailsTextBox"
Margin="0,10,0,0"
MinHeight="150"
MaxHeight="190"
AcceptsReturn="True"
TextWrapping="Wrap"
IsReadOnly="True"
BorderThickness="0"
FontSize="12"
Text="Stage: launch&#x0a;Code: unknown"
VerticalContentAlignment="Top" />
</Expander>
</Grid>
<Border Grid.Row="1"
Padding="24,16"
Background="#171A21">
<Grid ColumnDefinitions="*,Auto,Auto,Auto"
ColumnSpacing="8">
<Button x:Name="OpenLogButton"
Grid.Column="0"
Content="Open Logs"
MinWidth="108"
Height="34"
HorizontalAlignment="Left" />
Padding="18,14"
Background="{DynamicResource LayerOnMicaBaseAltFillColorDefaultBrush}">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="OpenLogButton"
MinWidth="112"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="FolderOpen" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Open Logs"/>
</StackPanel>
</Button>
<Button x:Name="SecondaryActionButton"
Grid.Column="1"
Content="Wait"
MinWidth="108"
Height="34"
IsVisible="False" />
<Button x:Name="CopyDetailsButton"
MinWidth="100"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Copy" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Copy"/>
</StackPanel>
</Button>
</StackPanel>
<Button x:Name="ExitButton"
Grid.Column="2"
Content="Exit"
MinWidth="90"
Height="34" />
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="SecondaryActionButton"
Content="Wait"
MinWidth="96"
Height="34"
IsVisible="False" />
<Button x:Name="PrimaryActionButton"
Grid.Column="3"
Content="Retry"
MinWidth="108"
Height="34" />
<Button x:Name="ExitButton"
MinWidth="92"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Dismiss" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Exit"/>
</StackPanel>
</Button>
<Button x:Name="PrimaryActionButton"
Classes="accent"
Content="Retry"
MinWidth="112"
Height="34" />
</StackPanel>
</Grid>
</Border>
</Grid>

View File

@@ -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<TextBlock>("ErrorMessageText") is { } errorText)
{
errorText.Text = message;
errorText.Text = firstLine;
}
if (this.FindControl<TextBox>("ErrorDetailsTextBox") is { } detailsTextBox)
{
detailsTextBox.Text = normalizedMessage;
}
}
@@ -120,6 +134,11 @@ public partial class ErrorWindow : Window
{
openLogButton.Click += OnOpenLogClick;
}
if (this.FindControl<Button>("CopyDetailsButton") is { } copyDetailsButton)
{
copyDetailsButton.Click += OnCopyDetailsClick;
}
}
private void ApplyActionLayout(
@@ -138,9 +157,9 @@ public partial class ErrorWindow : Window
titleText.Text = title;
}
if (this.FindControl<TextBlock>("SuggestionText") is { } suggestionText)
if (this.FindControl<FAInfoBar>("SuggestionInfoBar") is { } suggestionInfoBar)
{
suggestionText.Text = suggestion;
suggestionInfoBar.Message = suggestion;
}
if (this.FindControl<Button>("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<TextBox>("ErrorDetailsTextBox")?.Text;
if (string.IsNullOrWhiteSpace(details))
{
details = this.FindControl<TextBlock>("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";

View File

@@ -0,0 +1,124 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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.MultiInstancePromptWindow"
x:DataType="views:MultiInstancePromptWindow"
Title="LanMountain Desktop"
Width="620"
Height="360"
MinWidth="560"
MinHeight="330"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="Mica, AcrylicBlur, None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:MultiInstancePromptWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto">
<Grid Grid.Row="0"
Margin="28,26,28,20"
RowDefinitions="Auto,Auto,*"
ColumnDefinitions="Auto,*">
<Border Grid.Column="0"
Width="52"
Height="52"
Margin="0,0,18,0"
Background="{DynamicResource SystemFillColorAttentionBackgroundBrush}"
CornerRadius="26"
VerticalAlignment="Top">
<fi:SymbolIcon Symbol="Desktop"
IconVariant="Regular"
FontSize="26"
Foreground="{DynamicResource SystemFillColorAttentionBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="8">
<TextBlock Text="LanMountain Desktop is already running"
FontSize="22"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap" />
<TextBlock x:Name="MessageText"
Text="Launcher found an existing desktop instance and did not start another process."
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="22" />
</StackPanel>
<ui:FAInfoBar Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,22,0,14"
IsOpen="True"
IsClosable="False"
Severity="Informational"
Title="Repeated launch"
Message="Your current setting is to show this prompt without opening the desktop automatically.">
<ui:FAInfoBar.IconSource>
<ui:FAFontIconSource Glyph="&#xF0288;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FAInfoBar.IconSource>
</ui:FAInfoBar>
<TextBlock x:Name="DetailsText"
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="No second Host process was created." />
</Grid>
<Border Grid.Row="1"
Padding="18,14"
Background="{DynamicResource LayerOnMicaBaseAltFillColorDefaultBrush}">
<Grid ColumnDefinitions="*,Auto">
<Button x:Name="CopyDetailsButton"
Grid.Column="0"
MinWidth="104"
Height="34"
HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Copy" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Copy"/>
</StackPanel>
</Button>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="CloseButton"
MinWidth="92"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Dismiss" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Close"/>
</StackPanel>
</Button>
<Button x:Name="OpenDesktopButton"
Classes="accent"
MinWidth="136"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="ArrowRight" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Open desktop"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -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<MultiInstancePromptResult> _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<MultiInstancePromptResult> 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<TextBlock>("DetailsText") is { } detailsText)
{
detailsText.Text = _details;
}
}
private void OnLoaded(object? sender, RoutedEventArgs e)
{
if (this.FindControl<Button>("CloseButton") is { } closeButton)
{
closeButton.Click += (_, _) => Complete(MultiInstancePromptResult.Close);
}
if (this.FindControl<Button>("OpenDesktopButton") is { } openDesktopButton)
{
openDesktopButton.Click += (_, _) => Complete(MultiInstancePromptResult.OpenDesktop);
}
if (this.FindControl<Button>("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
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
public enum MultiInstanceLaunchBehavior
{
RestartApp,
OpenDesktopSilently,
PromptOnly,
NotifyAndOpenDesktop
}

View File

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

View File

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

View File

@@ -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<SettingsChangedEvent>? Changed;
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
=> typeof(T) == typeof(AppSettingsSnapshot)
? (T)(object)Snapshot
: new T();
public void SaveSnapshot<T>(SettingsScope scope, T snapshot, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection<string>? changedKeys = null)
{
}
public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new()
=> new();
public void SaveSection<T>(SettingsScope scope, string subjectId, string sectionId, T section, string? placementId = null, IReadOnlyCollection<string>? changedKeys = null)
{
}
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
{
}
public T? GetValue<T>(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null)
=> default;
public void SetValue<T>(SettingsScope scope, string key, T value, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection<string>? changedKeys = null)
{
}
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
=> throw new NotSupportedException();
}
}

View File

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

View File

@@ -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<StartupProgressMessage> _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)
{

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -26,6 +26,7 @@
<EmbeddedResource Include="Localization\*.json" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="WindowsIdentity\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -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": "コードネーム",

View File

@@ -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": "버전 코드명",

View File

@@ -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": "版本代号",

View File

@@ -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<MultiInstanceLaunchBehavior>))]
public MultiInstanceLaunchBehavior MultiInstanceLaunchBehavior { get; set; } =
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
public bool EnableFusedDesktop { get; set; } = false;
public List<string> DisabledPluginIds { get; set; } = [];
@@ -222,33 +228,38 @@ public sealed class AppSettingsSnapshot
#endregion
#region Notification Box Settings ()
#region Notification Box Settings
/// <summary>
/// 启用消息盒子功能Windows通知监听
/// Enables the system notification inbox component.
/// </summary>
public bool NotificationBoxEnabled { get; set; } = true;
/// <summary>
/// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
/// Hides notification details when unread messages are present.
/// </summary>
public bool NotificationBoxPrivacyMode { get; set; } = false;
/// <summary>
/// 被屏蔽的应用列表(不接收这些应用的通知)
/// App IDs that should not be collected by the notification box.
/// </summary>
public List<string> NotificationBoxBlockedApps { get; set; } = [];
/// <summary>
/// 历史记录保留天数
/// Number of days to retain notification box history.
/// </summary>
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
/// <summary>
/// 最大存储通知数量(防止内存无限增长)
/// Maximum number of notifications kept in memory.
/// </summary>
public int NotificationBoxMaxStoredCount { get; set; } = 500;
/// <summary>
/// Linux capture mode: ProxyDaemon or PassiveMonitor.
/// </summary>
public string NotificationBoxLinuxCaptureMode { get; set; } = "ProxyDaemon";
#endregion
public AppSettingsSnapshot Clone()

View File

@@ -84,40 +84,40 @@ public sealed class ComponentSettingsSnapshot
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
#region Notification Box Component Settings ()
#region Notification Box Component Settings
/// <summary>
/// 组件内最大显示通知数量
/// Maximum number of notifications displayed by this component.
/// </summary>
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
/// <summary>
/// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
/// Sort order: TimeDesc, TimeAsc, AppGroup.
/// </summary>
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
/// <summary>
/// 是否显示应用图标
/// Whether to show app icons.
/// </summary>
public bool NotificationBoxShowAppIcon { get; set; } = true;
/// <summary>
/// 是否显示时间戳
/// Whether to show timestamps.
/// </summary>
public bool NotificationBoxShowTimestamp { get; set; } = true;
/// <summary>
/// 时间格式Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
/// Time format: Relative or Absolute.
/// </summary>
public string NotificationBoxTimeFormat { get; set; } = "Relative";
/// <summary>
/// 是否按应用分组显示
/// Whether to group notifications by app.
/// </summary>
public bool NotificationBoxGroupByApp { get; set; } = false;
/// <summary>
/// 是否显示清除按钮
/// Whether to show the clear button.
/// </summary>
public bool NotificationBoxShowClearButton { get; set; } = true;

View File

@@ -3,52 +3,43 @@ using System;
namespace LanMountainDesktop.Models;
/// <summary>
/// 通知项数据模型
/// Notification captured by the desktop notification box.
/// </summary>
public sealed class NotificationItem
{
/// <summary>
/// 唯一标识
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// 应用ID如 WeChat, Outlook 等)
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 应用名称
/// </summary>
public string AppName { get; set; } = string.Empty;
/// <summary>
/// 应用图标路径或Base64
/// </summary>
public string? AppIconPath { get; set; }
/// <summary>
/// 通知标题
/// </summary>
public byte[]? AppIconBytes { get; set; }
public string Title { get; set; } = string.Empty;
/// <summary>
/// 通知内容
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 接收时间
/// </summary>
public DateTime ReceivedTime { get; set; } = DateTime.Now;
/// <summary>
/// 是否已读
/// </summary>
public bool IsRead { get; set; } = false;
public DateTimeOffset ReceivedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public bool IsRead { get; set; }
/// <summary>
/// 原始通知的额外数据(用于点击跳转)
/// </summary>
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";
}

View File

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

View File

@@ -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;
/// <summary>
/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。
/// </summary>
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<bool> 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<byte>.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<byte>.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();
}
}

View File

@@ -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;
/// <summary>
/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications
/// </summary>
[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+\"(?<value>.*)\"\\s*$", RegexOptions.Compiled);
private static readonly Regex DbusUIntRegex = new("^\\s*uint32\\s+(?<value>\\d+)\\s*$", RegexOptions.Compiled);
private static readonly Regex DesktopEntryHintRegex = new("\"desktop-entry\"\\s+variant\\s+string\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled);
private static readonly Regex ImagePathHintRegex = new("\"image-path\"\\s+variant\\s+string\\s+\"(?<value>[^\"]+)\"", 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;
}
/// <summary>
/// 初始化并启动DBus监听
/// </summary>
public async Task<bool> InitializeAsync()
public async Task<NotificationBoxStatus> 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<bool> 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<string>();
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}");
}
}
/// <summary>
/// 处理接收到的通知供DBus信号处理器调用
/// </summary>
private void TryParseNotify(IReadOnlyList<string> 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<bool> 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;
}
});
}
/// <summary>
/// 解析应用图标路径
/// </summary>
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);
}
/// <summary>
/// 去除HTML标签通知内容可能包含HTML
/// </summary>
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("&lt;", "<");
result = result.Replace("&gt;", ">");
result = result.Replace("&amp;", "&");
result = result.Replace("&quot;", "\"");
return result.Trim();
var result = Regex.Replace(html, "<[^>]+>", string.Empty);
return result
.Replace("&lt;", "<", StringComparison.Ordinal)
.Replace("&gt;", ">", StringComparison.Ordinal)
.Replace("&amp;", "&", StringComparison.Ordinal)
.Replace("&quot;", "\"", StringComparison.Ordinal)
.Trim();
}
private static string NormalizeAppId(string appName)
=> appName.ToLowerInvariant().Replace(" ", string.Empty, StringComparison.Ordinal);
private static string? TryMatchHint(IEnumerable<string> 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;
}
}

View File

@@ -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<NotificationBoxStatus> InitializeAsync(CancellationToken cancellationToken = default);
Task RequestPermissionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
/// <summary>
/// 跨平台通知监听服务
/// Cross-platform notification aggregation service used by the notification box widget.
/// </summary>
public sealed class NotificationListenerService : IDisposable
{
private readonly List<NotificationItem> _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<NotificationItem>? NotificationReceived;
public event EventHandler<string>? NotificationRemoved;
public event EventHandler<NotificationBoxStatus>? StatusChanged;
public NotificationListenerService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
/// <summary>
/// 初始化并启动监听
/// </summary>
public async Task InitializeAsync()
{
SetStatus(new NotificationBoxStatus(NotificationBoxServiceState.Starting, "正在启动通知监听...", "Starting"));
try
{
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(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));
}
}
/// <summary>
/// 添加通知(供平台监听器调用)
/// </summary>
public void SetStatus(NotificationBoxStatus status)
{
_status = status;
Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, status));
}
public void AddNotification(NotificationItem notification)
{
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(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;
}
/// <summary>
/// 移除通知
/// </summary>
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));
}
}
/// <summary>
/// 获取所有通知
/// </summary>
public IReadOnlyList<NotificationItem> GetNotifications()
{
lock (_lock)
@@ -156,20 +208,16 @@ public sealed class NotificationListenerService : IDisposable
}
}
/// <summary>
/// 清空所有通知
/// </summary>
public void ClearAll()
{
lock (_lock)
{
_notifications.Clear();
}
Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, _status));
}
/// <summary>
/// 标记通知为已读
/// </summary>
public void MarkAsRead(string notificationId)
{
lock (_lock)
@@ -182,9 +230,6 @@ public sealed class NotificationListenerService : IDisposable
}
}
/// <summary>
/// 获取未读通知数量
/// </summary>
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<StartMenuAppEntry> 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;
}
}
}

View File

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

View File

@@ -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<string, NotificationItem> _lastSnapshot = new(StringComparer.OrdinalIgnoreCase);
private readonly CancellationTokenSource _cts = new();
private object? _listener;
private Task? _pollTask;
public WindowsNotificationListener(NotificationListenerService parent)
{
_parent = parent;
}
public async Task<NotificationBoxStatus> 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<string>(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<NotificationItem?> 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<string>();
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<byte[]?> 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<string> 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<object?> 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);
}

View File

@@ -18,10 +18,10 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
MaxDisplayCountOptions = new ObservableCollection<SelectionOption>
{
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<SelectionOption>
@@ -33,7 +33,7 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
TimeFormatOptions = new ObservableCollection<SelectionOption>
{
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<SelectionOption> MaxDisplayCountOptions { get; }

View File

@@ -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<SelectionOption> 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<SelectionOption> Positions { get; }
@@ -202,6 +250,8 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
public ObservableCollection<SelectionOption> TestPositions { get; }
public ObservableCollection<SelectionOption> TestSeverities { get; }
public ObservableCollection<SelectionOption> 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()
{

View File

@@ -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<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
}
if (changedKeys.Contains(nameof(AppSettingsSnapshot.MultiInstanceLaunchBehavior)))
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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<SelectionOption> RenderModes { get; }
public IReadOnlyList<SelectionOption> MultiInstanceLaunchBehaviors { get; }
public IReadOnlyList<TimeZoneOption> 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<MultiInstanceLaunchBehavior>(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<SelectionOption> 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<TimeZoneOption> 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()

View File

@@ -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<SelectionOption> ChannelOptions { get; private set; } = [];
public IReadOnlyList<SelectionOption> SourceOptions { get; private set; } = [];
public IReadOnlyList<SelectionOption> 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<SelectionOption> CreateChannelOptions()
{
return
[
new(UpdateSettingsValues.ChannelStable, L("settings.update.channel_stable", "Stable")),
new(UpdateSettingsValues.ChannelPreview, L("settings.update.channel_preview", "Preview"))
];
}
private IReadOnlyList<SelectionOption> 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<SelectionOption> 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)

View File

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

View File

@@ -6,14 +6,15 @@
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
Padding="0">
<Grid x:Name="LayoutGrid"
RowDefinitions="Auto,*">
<Grid x:Name="HeaderGrid"
ColumnDefinitions="*,Auto">
ColumnDefinitions="Auto,*,Auto"
Margin="16,12,16,8">
<StackPanel x:Name="DateGroup"
Orientation="Horizontal"
VerticalAlignment="Top">
VerticalAlignment="Center">
<TextBlock x:Name="MonthTextBlock"
Text="7"
FontWeight="Bold"
@@ -27,21 +28,24 @@
TextTrimming="CharacterEllipsis" />
</StackPanel>
<StackPanel x:Name="MetaStack"
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Top">
<TextBlock x:Name="WeekdayTextBlock"
Text="周一"
TextAlignment="Right"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="WeekdayTextBlock"
Grid.Column="1"
Text="周一"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<Border x:Name="ClassCountBadge"
Grid.Column="2"
VerticalAlignment="Center"
Padding="8,3"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}">
<TextBlock x:Name="ClassCountTextBlock"
Text="0节课"
TextAlignment="Right"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
</Grid>
<Grid Grid.Row="1">

View File

@@ -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<StackPanel>().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<Border>().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<Border>().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)

View File

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

View File

@@ -1,250 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="640"
x:Class="LanMountainDesktop.Views.Components.ExtendedWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.26"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.07"
ScaleY="1.07" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.12" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.54">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#45FFFFFF"
Offset="0" />
<GradientStop Color="#16FFFFFF"
Offset="0.34" />
<GradientStop Color="#00000000"
Offset="0.66" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.70">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00000000"
Offset="0.40" />
<GradientStop Color="#1A000000"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="24,20"
Background="Transparent">
<Grid x:Name="LayoutRoot"
RowDefinitions="Auto,Auto,Auto,*">
<Grid x:Name="SummaryGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="16">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="7°"
FontSize="64"
FontWeight="Light"
VerticalAlignment="Center"
Margin="0,-2,0,0"
TextTrimming="None"
MaxLines="1" />
<Grid x:Name="SummaryInfoGrid"
Grid.Column="1"
VerticalAlignment="Center"
Margin="2,0,0,0"
RowDefinitions="Auto,Auto"
RowSpacing="2">
<StackPanel x:Name="BottomInfoStack"
Grid.Row="0"
Orientation="Horizontal"
Spacing="3"
Margin="0,0,0,1"
VerticalAlignment="Center">
<Border x:Name="CityInfoBadge"
Background="Transparent"
CornerRadius="0"
Padding="0">
<TextBlock x:Name="CityTextBlock"
Text="北京"
FontSize="18"
FontWeight="SemiBold"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Border>
</StackPanel>
<Border x:Name="ConditionInfoBadge"
Grid.Row="1"
Background="Transparent"
CornerRadius="0"
Padding="0"
Margin="0">
<StackPanel x:Name="ConditionRangeStack"
Orientation="Horizontal"
VerticalAlignment="Center"
Spacing="9">
<TextBlock x:Name="ConditionTextBlock"
Text="雾"
FontSize="20"
FontWeight="SemiBold"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="11°/4°"
FontSize="20"
FontWeight="SemiBold"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
MaxLines="1"
Opacity="0.92" />
</StackPanel>
</Border>
</Grid>
<Image x:Name="WeatherIconImage"
Grid.Column="2"
Width="72"
Height="72"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Stretch="Uniform" />
</Grid>
<Border x:Name="HourlyPanelBorder"
Grid.Row="1"
Background="Transparent"
CornerRadius="0"
ClipToBounds="True"
Padding="0,2,0,0"
Margin="0,10,0,0">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*,*"
ColumnSpacing="4">
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp0" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon0" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime0" Text="15:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp1" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon1" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime1" Text="16:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp2" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon2" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime2" Text="17:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp3" Text="日落" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon3" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime3" Text="18:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp4" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon4" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime4" Text="19:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="5" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp5" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon5" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime5" Text="20:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="SeparatorLine"
Grid.Row="2"
Height="1"
Margin="0,12,0,0"
Background="#25FFFFFF" />
<Grid x:Name="DailyGrid"
Grid.Row="3"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="10"
Margin="0,12,0,0">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
<Image x:Name="DailyIcon0" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="DailyLabel0" Grid.Column="1" Text="明天·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh0" Grid.Column="2" Text="10" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow0" Grid.Column="3" Text="5" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
<Image x:Name="DailyIcon1" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="DailyLabel1" Grid.Column="1" Text="周四·多云" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh1" Grid.Column="2" Text="13" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow1" Grid.Column="3" Text="4" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
<Image x:Name="DailyIcon2" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="DailyLabel2" Grid.Column="1" Text="周五·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh2" Grid.Column="2" Text="12" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow2" Grid.Column="3" Text="3" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
</Grid>
<Grid Grid.Row="3" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
<Image x:Name="DailyIcon3" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="DailyLabel3" Grid.Column="1" Text="周六·多云" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh3" Grid.Column="2" Text="10" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow3" Grid.Column="3" Text="2" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
<Image x:Name="DailyIcon4" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="DailyLabel4" Grid.Column="1" Text="周日·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh4" Grid.Column="2" Text="11" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow4" Grid.Column="3" Text="3" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
</Grid>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -1,177 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.HourlyWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.25"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.07" ScaleY="1.07" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.12" />
<Border x:Name="BackgroundLightLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.52">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#45FFFFFF" Offset="0" />
<GradientStop Color="#16FFFFFF" Offset="0.35" />
<GradientStop Color="#00000000" Offset="0.64" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.68">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#00000000" Offset="0.42" />
<GradientStop Color="#19000000" Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer" IsHitTestVisible="False" ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder" Padding="24,18" Background="Transparent">
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid" RowDefinitions="Auto,*" RowSpacing="8">
<Grid x:Name="TopRowGrid" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="7°"
FontSize="54"
FontWeight="Light"
VerticalAlignment="Center"
Margin="0,-2,0,0"
TextTrimming="None"
MaxLines="1" />
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="2"
Margin="2,0,0,0">
<StackPanel x:Name="BottomInfoStack"
Orientation="Horizontal"
Spacing="3"
Margin="0,0,0,1"
VerticalAlignment="Center">
<Border x:Name="CityInfoBadge"
Background="Transparent"
CornerRadius="0"
Padding="0">
<StackPanel Orientation="Horizontal" Spacing="0">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="13"
IsVisible="False"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Text="北京"
FontSize="17"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Border>
</StackPanel>
<Border x:Name="ConditionInfoBadge"
Background="Transparent"
CornerRadius="0"
Padding="0"
Margin="0">
<StackPanel x:Name="ConditionRangeStack"
Orientation="Horizontal"
VerticalAlignment="Center"
Spacing="9">
<TextBlock x:Name="ConditionTextBlock"
Text="雾"
FontSize="18"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="11°/4°"
FontSize="20"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
Opacity="0.92" />
</StackPanel>
</Border>
</StackPanel>
<Image x:Name="WeatherIconImage"
Grid.Column="2"
Width="66"
Height="66"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Stretch="Uniform" />
</Grid>
<Border x:Name="HourlyPanelBorder"
Grid.Row="1"
Background="Transparent"
CornerRadius="0"
ClipToBounds="True"
Padding="0,2,0,0"
VerticalAlignment="Top">
<Grid x:Name="HourlyGrid" ColumnDefinitions="*,*,*,*,*,*" ColumnSpacing="4">
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp0" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon0" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime0" Text="15:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp1" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon1" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime1" Text="16:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp2" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon2" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime2" Text="17:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp3" Text="日落" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon3" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime3" Text="18:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp4" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon4" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime4" Text="19:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
<StackPanel Grid.Column="5" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="HourlyTemp5" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon5" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
<TextBlock x:Name="HourlyTime5" Text="20:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
</StackPanel>
</Grid>
</Border>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

Some files were not shown because too many files have changed in this diff Show More