changed.更了好多
1
.gitignore
vendored
@@ -518,3 +518,4 @@ nul
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
/test-aot-publish
|
||||
/.claude/worktrees
|
||||
|
||||
403
.trae/documents/class-schedule-widget-redesign.md
Normal 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. 测试通过
|
||||
@@ -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.
|
||||
|
||||
212
.trae/documents/update-settings-redesign.md
Normal 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 选择能正确保存和加载
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<Style Selector="Window">
|
||||
<Setter Property="Topmost" Value="True" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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="󰊈"
|
||||
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
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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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="󰊈"
|
||||
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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public enum MultiInstanceLaunchBehavior
|
||||
{
|
||||
RestartApp,
|
||||
OpenDesktopSilently,
|
||||
PromptOnly,
|
||||
NotifyAndOpenDesktop
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
123
LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
106
LanMountainDesktop.Tests/NotificationListenerServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 422 B |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 910 B |
|
Before Width: | Height: | Size: 988 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 734 B |
|
Before Width: | Height: | Size: 618 B |
|
Before Width: | Height: | Size: 754 B |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 656 B |
|
Before Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 260 B |
|
Before Width: | Height: | Size: 477 B |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 152 B |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 683 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "コードネーム",
|
||||
|
||||
@@ -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": "버전 코드명",
|
||||
|
||||
@@ -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": "版本代号",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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("<", "<");
|
||||
result = result.Replace(">", ">");
|
||||
result = result.Replace("&", "&");
|
||||
result = result.Replace(""", "\"");
|
||||
return result.Trim();
|
||||
var result = Regex.Replace(html, "<[^>]+>", string.Empty);
|
||||
return result
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace("&", "&", StringComparison.Ordinal)
|
||||
.Replace(""", "\"", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeAppId(string appName)
|
||||
=> appName.ToLowerInvariant().Replace(" ", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
private static string? TryMatchHint(IEnumerable<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
504
LanMountainDesktop/Services/WindowsNotificationListener.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||