diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..b378090
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,13 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")",
+ "Read(//d/github/**)",
+ "Bash(dotnet build *)",
+ "Bash(dotnet test *)",
+ "Bash(python -)",
+ "Bash(py -3 -c \"from pathlib import Path; p=Path\\(r'd:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs'\\); t=p.read_text\\(encoding='utf-8'\\); s=t.find\\('public sealed partial class UpdateSettingsPageViewModel : ViewModelBase'\\); e=t.find\\('public sealed partial class StudySettingsPageViewModel : ViewModelBase', s\\); assert s!=-1 and e!=-1; p.write_text\\(t[:s]+t[e:], encoding='utf-8'\\); print\\('ok'\\)\")",
+ "Bash(perl -0777 -i -pe \"s/public sealed partial class UpdateSettingsPageViewModel : ViewModelBase\\\\R\\\\{.*?\\\\R\\\\}\\\\R\\\\Rpublic sealed partial class StudySettingsPageViewModel : ViewModelBase/public sealed partial class StudySettingsPageViewModel : ViewModelBase/s\" \"d:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs\")"
+ ]
+ }
+}
diff --git a/.comate/specs/standby-digital-clock/doc.md b/.comate/specs/standby-digital-clock/doc.md
new file mode 100644
index 0000000..d2bc6fe
--- /dev/null
+++ b/.comate/specs/standby-digital-clock/doc.md
@@ -0,0 +1,291 @@
+# StandBy Digital Clock - iPhone 待机风格大数字时钟组件
+
+## 1. 需求场景与处理逻辑
+
+### 1.1 需求描述
+新增一个 4×2 尺寸的数字时钟桌面组件,视觉风格参考 iPhone 横屏充电时的 StandBy 待机显示——大面积、粗体、圆润的数字显示当前时间(HH:MM),数字采用不规则的自由排版(有微妙的垂直偏移,不在一条直线上),颜色使用 Monet 主题色而非纯黑/白,伴随数字切换时的流畅垂直滚动/翻转动画,下方显示日期信息。
+
+### 1.2 用户体验目标
+- 大字号、圆润粗体的数字时间,远距离一目了然
+- 数字采用不规则自由排版(微妙垂直偏移),营造 iPhone StandBy 那种有机、散漫的视觉节奏
+- 数字使用 Monet 主题色(跟随壁纸/用户选色的强调色),而非死板的纯黑/白
+- 数字变化时执行垂直滑动动画(旧数字向上滑出,新数字从下方滑入),类似翻页时钟效果
+- 冒号(:)有呼吸闪烁效果
+- 支持夜间/日间模式自动切换
+- 点击组件可打开世界时钟 AirApp
+- 支持时区配置(与现有桌面时钟共享设置体系)
+
+### 1.3 处理逻辑
+1. 组件加载时读取时区设置和秒针模式设置
+2. `DispatcherTimer` 每秒触发一次更新
+3. 当检测到分钟数变化时,触发分钟数字的垂直滑动动画
+4. 当检测到小时数变化时,触发小时数字的垂直滑动动画
+5. 冒号以 1 秒周期做透明度脉冲动画
+6. 每 tick 检查是否需要切换日间/夜间视觉模式
+
+## 2. 架构与技术方案
+
+### 2.1 组件架构
+遵循现有桌面组件架构模式:
+- 继承 `UserControl`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware`
+- AXAML 定义根布局结构,代码后置处理动画逻辑
+- 通过 `DesktopComponentDefinition` 注册到组件系统
+
+### 2.2 数字滚动动画技术方案
+采用 Avalonia `RenderTransform` + `DoubleTransition` 实现数字滚动:
+
+**核心思路**:每个数位(共 4 位:H1, H2, M1, M2)使用 `ClipToBounds` 的容器,内含一个垂直排列的 `StackPanel`,包含当前数字和下一个数字。切换时通过 `TranslateTransform.Y` 的 `DoubleTransition` 实现平滑滚动。
+
+```
+每位数字的结构:
+┌─ DigitClip (ClipToBounds=true) ──────────┐
+│ ┌─ DigitStack (TranslateTransform.Y) ──┐ │
+│ │ [当前数字 TextBlock] │ │
+│ │ [新数字 TextBlock] │ │
+│ └───────────────────────────────────────┘ │
+└───────────────────────────────────────────┘
+```
+
+当数字变化时:
+1. 在 StackPanel 底部添加新数字的 TextBlock
+2. 将 `TranslateTransform.Y` 从 0 动画过渡到 `-digitHeight`
+3. 动画完成后移除旧数字,重置 Y 为 0
+
+### 2.3 动画参数
+- 使用项目 `FluttermotionToken` 体系:滚动动画时长 `FluttermotionToken.Standard`(200ms)
+- 缓动函数:`CubicEaseOut`(与项目现有动画风格一致)
+- 冒号呼吸动画:透明度 1.0 → 0.3 → 1.0,周期 2 秒,使用 `DoubleTransition`
+
+### 2.4 尺寸与布局
+- 组件定义:`MinWidthCells = 4, MinHeightCells = 2`
+- 缩放规则:2:1 比例(与 WorldClock 一致)
+- 内部布局采用 `Viewbox` 包裹,确保在不同 cellSize 下自适应缩放
+- 数字字体大小:基准设计为 130px(在 Viewbox 内),实际显示由 Viewbox 缩放
+
+### 2.5 布局风格——不规则自由排版(iPhone StandBy 风格)
+iPhone StandBy 的数字不是规矩地排成一条直线,而是有微妙的垂直偏移和大小差异,营造出自由散漫、有机的视觉节奏:
+
+```
+ H1 H2 : M1 M2
+ ↗↘ ↘↗ ↗↘ ↘↗
+ ↕+6 ↕+2 : ↕+4 ↕+2
+ ↖ -3° ↗ +4° : ↖ -1° ↗ +5°
+ ←+6,↑-10 ←-2,↓+10 →+4,↑-3 ←-2,↓+12
+```
+
+每个数字有三个自由度:
+- **垂直偏移 (Y)**:H1=-10, H2=+10, 冒号=+8, M1=-3, M2=+12
+- **水平偏移 (X)**:H1=+6, H2=-2, 冒号=0, M1=+4, M2=-2
+- **旋转角度 (Z)**:H1=-4°, H2=+3°, 冒号=-1°, M1=-2°, M2=+5°
+
+### 2.6 视觉风格——圆润粗体 + Monet 主题色
+- **字体**:`FontWeight.Bold`,配合较大的字号,视觉上圆润饱满
+- **颜色**:使用项目 Monet 主题色系统,数字颜色跟随 `AdaptiveAccentBrush` / `SystemAccentColor`,而非纯黑/白
+ - 数字颜色通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
+ - 跟随系统:使用 `AdaptiveAccentBrush`(Monet 提取的强调色)
+ - 原生模式:使用组件自带的特色色彩
+ - 夜间模式:深色渐变背景 + 主题色数字(亮色调)
+ - 日间模式:浅色渐变背景 + 主题色数字(深色调)
+ - 夜间暗光环境:数字过渡到柔和的红色调(`#FF6B4A`),模拟 iPhone StandBy 夜间红色调
+- **冒号颜色**:与数字同色,但有呼吸动画
+- **日期行**:使用 `AdaptiveTextMutedBrush`(跟随主题的弱化文字色),字号约 14-16px 基准
+- **根容器圆角**:`DesignCornerRadiusComponent`(遵循圆角规范)
+
+## 3. 受影响文件
+
+### 3.1 新增文件
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | 新增 | 组件 AXAML 布局 |
+| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 新增 | 组件代码后置(动画逻辑、时间更新、模式切换) |
+
+### 3.2 修改文件
+| 文件 | 修改类型 | 受影响函数/区域 |
+|------|----------|-----------------|
+| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增常量 | 新增 `DesktopStandbyDigitalClock` 常量 |
+| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 新增定义 | `CreateDefault()` 中新增组件定义 |
+| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增运行时注册 | `GetDefaultRegistrations()` 中新增运行时注册项 |
+| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | 新增缩放规则 | `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 |
+
+## 4. 实现细节
+
+### 4.1 BuiltInComponentIds 新增常量
+```csharp
+public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
+```
+
+### 4.2 ComponentRegistry 新增定义
+```csharp
+new DesktopComponentDefinition(
+ BuiltInComponentIds.DesktopStandbyDigitalClock,
+ "StandBy Clock",
+ "Clock",
+ "Clock",
+ MinWidthCells: 4,
+ MinHeightCells: 2,
+ AllowStatusBarPlacement: false,
+ AllowDesktopPlacement: true),
+```
+
+### 4.3 DesktopComponentRuntimeRegistry 新增注册
+```csharp
+new DesktopComponentRuntimeRegistration(
+ BuiltInComponentIds.DesktopStandbyDigitalClock,
+ "component.standby_digital_clock",
+ () => new StandbyDigitalClockWidget()),
+```
+
+### 4.4 NormalizeAspectRatioForComponent 缩放规则
+在 `case BuiltInComponentIds.DesktopWorldClock:` 的同一分支中添加 `BuiltInComponentIds.DesktopStandbyDigitalClock`,使用 2:1 比例规则。
+
+### 4.5 AXAML 布局结构
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 4.6 数字滚动动画核心代码(伪代码)
+```csharp
+private void AnimateDigit(Border clip, Panel stack, TextBlock currentText, char newDigit, double digitHeight)
+{
+ var oldText = currentText;
+ var newTextBlock = new TextBlock
+ {
+ Text = newDigit.ToString(),
+ FontSize = oldText.FontSize,
+ FontWeight = oldText.FontWeight,
+ Foreground = oldText.Foreground,
+ Width = oldText.Width,
+ Height = digitHeight,
+ // 复制旧文本的所有样式属性
+ };
+ stack.Children.Add(newTextBlock);
+
+ // 应用 TranslateTransform 过渡动画
+ var transform = new TranslateTransform { Y = 0 };
+ stack.RenderTransform = transform;
+ stack.Transitions = new Transitions
+ {
+ new DoubleTransition(TranslateTransform.YProperty, FluttermotionToken.Standard, new CubicEaseOut())
+ };
+
+ // 触发动画:从当前位置滑到 -digitHeight
+ transform.Y = -digitHeight;
+
+ // 动画完成后清理
+ _ = DispatcherTimer.RunOnce(() =>
+ {
+ stack.Children.Remove(oldText);
+ transform.Y = 0;
+ stack.Transitions = null; // 移除过渡,避免重置时再次动画
+ // 更新引用
+ UpdateCurrentTextReference(newTextBlock);
+ }, FluttermotionToken.Standard);
+}
+```
+
+### 4.7 冒号呼吸动画
+使用 `DispatcherTimer` 每秒切换冒号透明度:
+```csharp
+private void ToggleColonOpacity()
+{
+ _colonVisible = !_colonVisible;
+ ColonText.Opacity = _colonVisible ? 1.0 : 0.3;
+}
+```
+配合 `DoubleTransition` 使透明度变化平滑过渡。
+
+### 4.8 日间/夜间模式
+与 `AnalogClockWidget` 使用完全相同的判断逻辑:
+- 检查 `ActualThemeVariant`
+- 回退到 `AdaptiveSurfaceBaseBrush` 亮度计算
+- 夜间模式:深色渐变背景 + 浅色数字
+- 日间模式:浅色渐变背景 + 深色数字
+
+### 4.9 时区与设置
+- 复用 `AnalogClockWidget` 的时区解析和设置加载逻辑
+- 使用 `ComponentSettingsSnapshot.DesktopClockTimeZoneId` 读取时区配置
+- 点击打开世界时钟 AirApp
+
+## 5. 边界条件与异常处理
+
+| 场景 | 处理方式 |
+|------|----------|
+| 组件首次加载时数字尚未初始化 | 在构造函数中初始化所有数字为当前时间,不触发动画 |
+| 快速连续触发数字变化(如时间同步导致跳变) | 在动画完成前忽略新的变化请求,或中断当前动画立即跳转到目标值 |
+| cellSize 极小或极大 | `ApplyCellSize` 中 clamp 缩放因子(0.58-1.95,与 AnalogClockWidget 一致) |
+| 时区切换 | 重新加载设置并更新所有数字(无动画,直接设置) |
+| 主题切换 | 通过 `ApplyModeVisualIfNeeded()` 在下一个 tick 自动检测并切换 |
+| 组件被销毁 | `DetachedFromVisualTree` 停止 timer,清理资源 |
+| 冒号动画在组件不可见时 | timer 仍在运行但 Opacity 变化无性能开销;若需要可结合 `IDesktopPageVisibilityAwareComponentWidget` |
+
+## 6. 数据流路径
+
+```
+DispatcherTimer (1s interval)
+ → OnTimerTick
+ → 计算当前时间 (TimeZoneInfo.ConvertTimeFromUtc)
+ → 比较新旧时间数字
+ → 若有变化: AnimateDigit() 执行滚动动画
+ → ToggleColonOpacity() 切换冒号
+ → ApplyModeVisualIfNeeded() 检查日/夜间切换
+ → UpdateDateText() 更新日期文本
+
+用户点击 → OnPointerReleased → AirAppLauncherServiceProvider.OpenWorldClock()
+
+时区变更 → TimeZoneChanged event → RefreshFromSettings() → 无动画更新所有数字
+```
+
+## 7. 预期成果
+
+- 在桌面组件选择器中新增 "StandBy Clock" 组件,位于 Clock 分类
+- 拖放到桌面后显示 4×2 大数字时钟
+- 数字切换时有流畅的垂直滑动动画
+- 冒号有呼吸闪烁效果
+- 支持日间/夜间自动切换
+- 支持时区配置
+- 支持组件缩放(2:1 比例规则)
diff --git a/.comate/specs/standby-digital-clock/summary.md b/.comate/specs/standby-digital-clock/summary.md
new file mode 100644
index 0000000..a502339
--- /dev/null
+++ b/.comate/specs/standby-digital-clock/summary.md
@@ -0,0 +1,52 @@
+# StandBy Digital Clock 实现总结
+
+## 完成状态
+全部任务已完成,构建通过(0 错误)。
+
+## 变更清单
+
+### 新增文件
+| 文件 | 说明 |
+|------|------|
+| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期,Monet 主题色绑定 |
+| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置:数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 |
+
+### 修改文件
+| 文件 | 改动 |
+|------|------|
+| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 |
+| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 |
+| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 |
+| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 |
+
+## 核心设计要点
+
+### 不规则自由排版(iPhone StandBy 风格)
+- 每个数字有独立的垂直 Margin 偏移(H1 上移10, H2 下移8, M1 上移5, M2 下移10)
+- 冒号比数字中心略低(下移6)
+- 数字间距不等,营造自由散漫的视觉节奏
+
+### Monet 主题色
+- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色
+- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
+ - 跟随系统:使用 Monet 提取的强调色
+ - 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy
+- 日期文本使用 `AdaptiveTextMutedBrush`
+
+### 数字滚动动画
+- `TranslateTransform.Y` + `DoubleTransition`(200ms CubicEaseOut)
+- 动画完成后清理旧 TextBlock 并重置 transform
+
+### 冒号呼吸
+- 每秒切换 Opacity(1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡
+
+### 日/夜模式
+- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算
+- 夜间:深色渐变背景 + 亮调强调色数字
+- 日间:浅色渐变背景 + 深调强调色数字
+
+### 组件规格
+- 尺寸:4×2 (MinWidthCells=4, MinHeightCells=2)
+- 分类:Clock
+- 缩放:2:1 比例 (Proportional)
+- 字体:FontWeight.Bold, 120px 基准
diff --git a/.comate/specs/standby-digital-clock/tasks.md b/.comate/specs/standby-digital-clock/tasks.md
new file mode 100644
index 0000000..6cb7dab
--- /dev/null
+++ b/.comate/specs/standby-digital-clock/tasks.md
@@ -0,0 +1,25 @@
+# StandBy Digital Clock 任务计划
+
+- [x] Task 1: 注册组件定义与运行时
+ - 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量
+ - 1.2: 在 `ComponentRegistry.cs` 的 `CreateDefault()` 中新增 `DesktopComponentDefinition`(4×2, Clock 分类, Proportional)
+ - 1.3: 在 `DesktopComponentRuntimeRegistry.cs` 的 `GetDefaultRegistrations()` 中新增运行时注册项
+ - 1.4: 在 `MainWindow.ComponentSystem.cs` 的 `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则
+
+- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局
+ - 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 RootBorder(DesignCornerRadiusComponent)、Viewbox、时间数字区域(4 个 ClipToBounds 数位容器 + 冒号)、日期文本
+ - 2.2: 确保 Viewbox 内基准设计尺寸为 400×200,数字使用 FontWeight.Bold,冒号和日期布局合理
+
+- [x] Task 3: 实现组件代码后置(核心逻辑与动画)
+ - 3.1: 创建 `StandbyDigitalClockWidget.axaml.cs`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware` 接口
+ - 3.2: 实现 DispatcherTimer 每秒更新逻辑,比较新旧时间数字,触发数位滚动动画
+ - 3.3: 实现数字垂直滚动动画:每位数字使用 TranslateTransform.Y + DoubleTransition,旧数字上滑出新数字滑入,动画完成后清理
+ - 3.4: 实现冒号呼吸动画:每秒切换透明度,配合 DoubleTransition 平滑过渡
+ - 3.5: 实现日间/夜间模式切换:检测 ActualThemeVariant 和亮度,切换背景渐变和数字颜色;夜间暗光环境过渡到红色调
+ - 3.6: 实现 ApplyCellSize 缩放逻辑,clamp 缩放因子,更新圆角和间距
+ - 3.7: 实现时区设置加载(复用 AnalogClockWidget 逻辑),点击打开世界时钟 AirApp
+ - 3.8: 实现日期文本更新逻辑,显示完整日期和星期
+
+- [x] Task 4: 构建验证与调试
+ - 4.1: 执行 `dotnet build` 确保编译通过,修复所有错误
+ - 4.2: 检查圆角规范合规性(根容器使用 DesignCornerRadiusComponent)
diff --git a/.cursor/skills/.gitkeep b/.cursor/skills/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/.cursor/skills/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/.github/workflows/ddss-publish.yml b/.github/workflows/ddss-publish.yml
index 718407a..ee76ab6 100644
--- a/.github/workflows/ddss-publish.yml
+++ b/.github/workflows/ddss-publish.yml
@@ -1,5 +1,9 @@
name: DDSS
+concurrency:
+ group: ddss-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
+ cancel-in-progress: false
+
on:
workflow_run:
workflows:
@@ -31,7 +35,7 @@ jobs:
fetch-depth: 0
submodules: recursive
- - name: Resolve release tag
+ - name: Resolve release tag and channel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
@@ -50,7 +54,21 @@ jobs:
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
- echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV"
+ IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
+ if [[ "$IS_PRERELEASE" == "true" ]]; then
+ CHANNEL="preview"
+ else
+ CHANNEL="stable"
+ fi
+ echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
+ echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
+ PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
+ if [[ -z "$PUBLIC_BASE" ]]; then
+ PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
+ fi
+ PUBLIC_BASE="${PUBLIC_BASE%/}"
+ echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
+ echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -89,6 +107,25 @@ jobs:
gh release download "$RELEASE_TAG" -D release-assets
find release-assets -maxdepth 1 -type f | sort
+ - name: Prepare PLONDS static output
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rm -rf plonds-static
+ mkdir -p plonds-static
+ if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
+ gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
+ fi
+ if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
+ unzip -q release-assets/plonds-static.zip -d plonds-static
+ fi
+ if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
+ echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
+ exit 1
+ fi
+
- name: Upload release assets to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
@@ -121,6 +158,59 @@ jobs:
--metadata "sha256=$sha256"
done
+ - name: Upload PLONDS static output to Rainyun S3
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
+ AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
+ AWS_REGION: ${{ vars.S3_REGION }}
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
+ plonds-static/ \
+ "s3://$S3_BUCKET/lanmountain/update/" \
+ --only-show-errors
+
+ - name: Mirror installers to Rainyun S3
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
+ AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
+ AWS_REGION: ${{ vars.S3_REGION }}
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="${RELEASE_TAG#v}"
+ for file in release-assets/*; do
+ [[ -f "$file" ]] || continue
+ name="$(basename "$file")"
+ platform=""
+ case "$name" in
+ *.exe)
+ if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
+ ;;
+ *.deb)
+ platform="linux-x64"
+ ;;
+ *.dmg)
+ if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
+ ;;
+ esac
+ [[ -n "$platform" ]] || continue
+ key="lanmountain/update/installers/${platform}/${version}/${name}"
+ sha256="$(sha256sum "$file" | awk '{print $1}')"
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
+ --bucket "$S3_BUCKET" \
+ --key "$key" \
+ --body "$file" \
+ --metadata "sha256=$sha256"
+ done
+
- name: Build DDSS manifest
shell: bash
run: |
@@ -135,6 +225,33 @@ jobs:
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
+ - name: Validate DDSS asset references in Rainyun S3
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
+ AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
+ AWS_REGION: ${{ vars.S3_REGION }}
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' ddss-output/ddss.json \
+ | sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
+ | sort -u)
+
+ if [[ -z "$keys" ]]; then
+ echo "No S3-backed asset URLs found in ddss.json"
+ exit 1
+ fi
+
+ while IFS= read -r key; do
+ [[ -n "$key" ]] || continue
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
+ --bucket "$S3_BUCKET" \
+ --key "$key" >/dev/null
+ done <<< "$keys"
+
- name: Upload DDSS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -143,7 +260,7 @@ jobs:
set -euo pipefail
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
- - name: Upload DDSS manifest to Rainyun S3
+ - name: Upload DDSS manifest to Rainyun S3 staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
@@ -164,3 +281,101 @@ jobs:
--body "$file" \
--metadata "sha256=$sha256"
done
+
+ - name: Prepare DDSS channel pointer
+ shell: bash
+ run: |
+ set -euo pipefail
+ pointer_file="ddss-output/ddss-latest.json"
+ cat > "$pointer_file" <<'JSON'
+ {
+ "schemaVersion": 1,
+ "channel": "__CHANNEL__",
+ "releaseTag": "__TAG__",
+ "version": "__VERSION__",
+ "updatedAt": "__UPDATED_AT__",
+ "manifest": {
+ "url": "__MANIFEST_URL__",
+ "signatureUrl": "__SIG_URL__"
+ }
+ }
+ JSON
+
+ manifest_url="${S3_BASE_URL}/ddss.json"
+ sig_url="${S3_BASE_URL}/ddss.json.sig"
+ version="${RELEASE_TAG#v}"
+ updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+ sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file"
+ sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file"
+ sed -i "s|__VERSION__|${version}|g" "$pointer_file"
+ sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file"
+ sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file"
+ sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file"
+
+ jq -e . "$pointer_file" >/dev/null
+
+ - name: Atomically publish DDSS channel pointer
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
+ AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
+ AWS_REGION: ${{ vars.S3_REGION }}
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ pointer_file="ddss-output/ddss-latest.json"
+ staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json"
+
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
+ --bucket "$S3_BUCKET" \
+ --key "$staging_key" \
+ --body "$pointer_file"
+
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
+ --bucket "$S3_BUCKET" \
+ --key "$DDSS_CHANNEL_POINTER_KEY" \
+ --body "$pointer_file"
+
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
+ --bucket "$S3_BUCKET" \
+ --key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
+
+ curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
+
+ - name: Verify Rainyun S3 PLONDS output
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
+ AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
+ AWS_REGION: ${{ vars.S3_REGION }}
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ mapfile -t required < <(
+ {
+ find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
+ find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
+ find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
+ find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
+ find plonds-static/repo/sha256 -type f | sort | head -n 1
+ } | sed '/^$/d'
+ )
+
+ if [[ "${#required[@]}" -lt 5 ]]; then
+ echo "Not enough PLONDS static files to verify."
+ exit 1
+ fi
+
+ for path in "${required[@]}"; do
+ rel="${path#plonds-static/}"
+ key="lanmountain/update/${rel}"
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
+ --bucket "$S3_BUCKET" \
+ --key "$key" >/dev/null
+ curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
+ done
diff --git a/.github/workflows/ddss-rollback.yml b/.github/workflows/ddss-rollback.yml
new file mode 100644
index 0000000..c8dacd7
--- /dev/null
+++ b/.github/workflows/ddss-rollback.yml
@@ -0,0 +1,146 @@
+name: DDSS Rollback
+
+on:
+ workflow_dispatch:
+ inputs:
+ channel:
+ description: 'Target channel to rollback'
+ required: true
+ type: choice
+ default: stable
+ options:
+ - stable
+ - preview
+ target_tag:
+ description: 'Release tag to rollback to (e.g. v1.2.3)'
+ required: true
+ type: string
+
+env:
+ DOTNET_VERSION: '10.0.x'
+
+jobs:
+ rollback:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ concurrency:
+ group: ddss-rollback-${{ github.event.inputs.channel }}
+ cancel-in-progress: false
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Resolve rollback context
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ RAW_TAG="${{ github.event.inputs.target_tag }}"
+ if [[ "$RAW_TAG" == v* ]]; then
+ TAG="$RAW_TAG"
+ else
+ TAG="v$RAW_TAG"
+ fi
+
+ CHANNEL="${{ github.event.inputs.channel }}"
+
+ gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
+
+ PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
+ if [[ -z "$PUBLIC_BASE" ]]; then
+ PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
+ fi
+ PUBLIC_BASE="${PUBLIC_BASE%/}"
+
+ echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
+ echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
+ echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
+ echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
+ echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
+
+ - name: Validate rollback target assets
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
+ AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
+ AWS_REGION: ${{ vars.S3_REGION }}
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ for name in ddss.json ddss.json.sig; do
+ key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
+ --bucket "$S3_BUCKET" \
+ --key "$key" >/dev/null
+ done
+
+ - name: Build rollback pointer
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ mkdir -p rollback-output
+ pointer_file="rollback-output/ddss-latest.json"
+
+ manifest_url="${S3_BASE_URL}/ddss.json"
+ sig_url="${S3_BASE_URL}/ddss.json.sig"
+ version="${RELEASE_TAG#v}"
+ updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+ cat > "$pointer_file" </dev/null
+
+ - name: Publish rollback pointer
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
+ AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
+ AWS_REGION: ${{ vars.S3_REGION }}
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ pointer_file="rollback-output/ddss-latest.json"
+
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
+ --bucket "$S3_BUCKET" \
+ --key "$DDSS_CHANNEL_POINTER_KEY" \
+ --body "$pointer_file"
+
+ aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
+ --bucket "$S3_BUCKET" \
+ --key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
+
+ curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
+
+ - name: Print rollback summary
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
+ echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json"
diff --git a/.github/workflows/plonds-build.yml b/.github/workflows/plonds-build.yml
index 3f53ade..8eb99ff 100644
--- a/.github/workflows/plonds-build.yml
+++ b/.github/workflows/plonds-build.yml
@@ -1,10 +1,15 @@
name: PLONDS
+concurrency:
+ group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
+ cancel-in-progress: false
+
on:
release:
types:
- published
- prereleased
+ - edited
workflow_dispatch:
inputs:
tag:
@@ -66,6 +71,11 @@ jobs:
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
+ PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
+ if [[ -z "$PUBLIC_BASE" ]]; then
+ PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
+ fi
+ echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -189,7 +199,9 @@ jobs:
'--current-zip', $currentZip,
'--output-dir', 'plonds-output',
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
- '--channel', $plan.channel
+ '--channel', $plan.channel,
+ '--static-output-dir', 'plonds-output/static',
+ '--update-base-url', $env:S3_PUBLIC_BASE_URL
)
if ([bool]$entry.isFullPayload) {
@@ -212,6 +224,29 @@ jobs:
--output-dir plonds-output `
--private-key $env:UPDATE_PRIVATE_KEY_PATH
+ foreach ($entry in $plan.platforms) {
+ $summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json
+ $required = @(
+ "plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json",
+ "plonds-output/static/meta/distributions/$($summary.distributionId).json",
+ "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
+ "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
+ )
+
+ foreach ($path in $required) {
+ if (-not (Test-Path $path)) {
+ throw "Missing PLONDS static output: $path"
+ }
+ }
+ }
+
+ $objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue
+ if (-not $objects -or $objects.Count -eq 0) {
+ throw "PLONDS static object repository is empty."
+ }
+
+ Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
+
- name: Upload PLONDS assets to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -233,3 +268,11 @@ jobs:
path: plonds-run-metadata/tag.txt
if-no-files-found: error
retention-days: 7
+
+ - name: Upload PLONDS static artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: plonds-static
+ path: plonds-output/static/**
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ceeb801..dd72a74 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -179,6 +179,7 @@ jobs:
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
+ -p:SkipAirAppHostBuild=true `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
@@ -193,6 +194,7 @@ jobs:
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
+ -p:SkipAirAppHostBuild=true `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
@@ -202,6 +204,48 @@ jobs:
}
shell: pwsh
+ - name: Publish AirAppHost
+ run: |
+ $arch = "${{ matrix.arch }}"
+ $selfContained = "${{ matrix.self_contained }}" -eq "true"
+ $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
+
+ if ($selfContained) {
+ dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
+ -c Release `
+ -o ./$publishDir `
+ --self-contained:false `
+ -r win-$arch `
+ -p:PublishSingleFile=false `
+ -p:DebugType=none `
+ -p:DebugSymbols=false `
+ -p:PublishTrimmed=false `
+ -p:PublishReadyToRun=false `
+ -p:BuildingAirAppHost=true `
+ -p:SkipAirAppHostBuild=true `
+ -p:Version=${{ needs.prepare.outputs.version }} `
+ -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
+ -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
+ -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
+ } else {
+ dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
+ -c Release `
+ -o ./$publishDir `
+ --self-contained:false `
+ -p:PublishSingleFile=false `
+ -p:DebugType=none `
+ -p:DebugSymbols=false `
+ -p:PublishTrimmed=false `
+ -p:PublishReadyToRun=false `
+ -p:BuildingAirAppHost=true `
+ -p:SkipAirAppHostBuild=true `
+ -p:Version=${{ needs.prepare.outputs.version }} `
+ -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
+ -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
+ -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
+ }
+ shell: pwsh
+
- name: Restructure for Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
@@ -227,6 +271,18 @@ jobs:
Move-Item -Path $newStructure -Destination $publishDir -Force
shell: pwsh
+ - name: Optimize and Guard Windows Payload
+ run: |
+ $arch = "${{ matrix.arch }}"
+ $selfContained = "${{ matrix.self_contained }}" -eq "true"
+ $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
+
+ ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 `
+ -PublishDir $publishDir `
+ -RuntimeIdentifier "win-$arch" `
+ -AssertClean
+ shell: pwsh
+
- name: Install Inno Setup and 7z
run: |
choco install innosetup -y --no-progress
@@ -418,6 +474,7 @@ jobs:
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
+ -p:SkipAirAppHostBuild=true \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:Version=${{ needs.prepare.outputs.version }} \
@@ -606,6 +663,7 @@ jobs:
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
+ -p:SkipAirAppHostBuild=true \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:Version=${{ needs.prepare.outputs.version }} \
diff --git a/.gitignore b/.gitignore
index a2037bb..8f095ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@
# dotenv files
.env
+# Local NuGet global packages (NuGet.Config globalPackagesFolder)
+.nuget/packages/
+
# User-specific files
*.rsuser
*.suo
@@ -515,3 +518,4 @@ nul
/velopack-output-local-verify
/velopack-output-local
/test-aot-publish
+/.claude/worktrees
diff --git a/.trae/documents/class-schedule-widget-redesign.md b/.trae/documents/class-schedule-widget-redesign.md
new file mode 100644
index 0000000..f687fae
--- /dev/null
+++ b/.trae/documents/class-schedule-widget-redesign.md
@@ -0,0 +1,403 @@
+# 课程表组件视觉重构 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 彻底重构阑山桌面的课程表(ClassScheduleWidget)组件视觉设计,参考小爱课程表的桌面小部件风格,实现时间轴+色块卡片布局、科目自动配色、当前课程进度高亮等现代化视觉效果。
+
+**Architecture:** 保留现有数据层(ClassIslandScheduleDataService、Models)和组件注册机制不变,仅重构 Widget 的 UI 渲染层(XAML + code-behind 中的渲染逻辑)。新增科目配色服务,为每门课程分配稳定的区分色。先创建 HTML Mock 验证视觉效果,再移植到 Avalonia XAML。
+
+**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览)
+
+---
+
+## 当前状态分析
+
+### 现有组件结构
+- **XAML**: `ClassScheduleWidget.axaml` — 仅定义了 RootBorder、HeaderGrid(日期+星期+课数)、ScrollViewer+CourseListPanel、StatusTextBlock
+- **Code-behind**: `ClassScheduleWidget.axaml.cs` — 所有课程项 UI 在 `CreateSingleItemControl()` 中手动构建:圆点(Bullet) + 文字栈(课程名/时间/详情)
+- **数据层**: `ClassIslandScheduleDataService` + `ClassIslandScheduleModels` — 不变
+- **编辑器**: `ClassScheduleComponentEditor.axaml(.cs)` — 不变
+
+### 现有设计问题
+1. **视觉单调**: 仅用小圆点区分课程,所有课程外观一致,缺乏层次感
+2. **信息密度低**: 课程名、时间、教师名挤在一行,可读性差
+3. **当前课不突出**: 仅通过圆点颜色变化标识当前课程,几乎无法一眼识别
+4. **色彩硬编码**: 颜色值直接写在 C# 中,不使用语义资源键,不遵循 VISUAL_SPEC
+5. **无时间轴感**: 列表式排列无法体现课程的时间先后和持续长度
+
+### 小爱课程表参考设计特征
+1. **时间轴布局**: 左侧显示时间刻度,右侧是课程色块卡片
+2. **科目配色**: 每门课程自动分配一种柔和区分色,卡片使用对应色块背景
+3. **当前课高亮**: 正在进行的课程有明显的视觉强调(放大/进度条/发光)
+4. **进度指示**: 当前课程显示上课进度(已过时间/总时长)
+5. **紧凑信息**: 课程名+教室/教师信息在色块内清晰排列
+6. **课间分隔**: 课间休息区域有视觉分隔(虚线/淡色区域)
+
+---
+
+## 设计方案
+
+### 视觉论文 (Visual Thesis)
+时间轴驱动的色块卡片布局,柔和科目配色,当前课程进度高亮——在桌面小组件有限空间内实现信息密度与美感的平衡。
+
+### 布局结构
+```
+┌─────────────────────────────────────┐
+│ 7/24 周一 今天3节课 │ ← 头部:日期 + 星期 + 课数
+├─────────────────────────────────────┤
+│ 08:00 ┌──────────────────────┐ │
+│ │ 语文 │ │ ← 科目色块卡片
+│ │ 王老师 · 教室301 │ │
+│ 08:45 └──────────────────────┘ │
+│ ┌──────────────────────┐ │
+│ │ 数学 ████████░░ 75% │ │ ← 当前课:进度条 + 高亮
+│ │ 李老师 · 教室205 │ │
+│ 09:30 └──────────────────────┘ │
+│ ... │
+└─────────────────────────────────────┘
+```
+
+### 科目配色方案
+使用一组预定义的柔和色彩,按科目名哈希值稳定分配:
+- 语文: #5B8FF9 (蓝)
+- 数学: #F6903D (橙)
+- 英语: #5AD8A6 (绿)
+- 物理: #E8684A (红)
+- 化学: #9270CA (紫)
+- 生物: #FF9845 (琥珀)
+- 历史: #1E9493 (青)
+- 地理: #FF99C3 (粉)
+- 政治: #7262FD (靛)
+- 体育: #78D3F8 (天蓝)
+- 默认: #8B95A5 (灰)
+
+### 当前课程高亮
+- 卡片左侧显示 3px 宽的强调色竖条
+- 卡片底部显示细进度条(已过时间/总时长)
+- 卡片背景使用科目色的 15% 透明度版本
+- 非当前课程使用科目色的 8% 透明度版本
+
+---
+
+## 文件变更清单
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml` | 修改 | 重构 XAML 布局:时间轴+卡片区域 |
+| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs` | 修改 | 重构渲染逻辑:色块卡片、科目配色、进度条 |
+| `LanMountainDesktop/Views/Components/SubjectColorService.cs` | 新建 | 科目配色服务:稳定哈希分配颜色 |
+| `mocks/class-schedule-mock.html` | 新建 | HTML Mock 预览(亮色+暗色) |
+
+---
+
+## Task 分解
+
+### Task 1: 创建 HTML Mock 预览
+
+**Files:**
+- Create: `mocks/class-schedule-mock.html`
+
+- [ ] **Step 1: 创建 HTML Mock 文件**
+
+创建完整的 HTML Mock,包含:
+- 亮色/暗色主题切换
+- 时间轴+色块卡片布局
+- 科目自动配色
+- 当前课程进度条高亮
+- 课间分隔区域
+- 响应式尺寸(模拟桌面组件 2x4 / 4x4 等尺寸)
+
+Mock 中应包含示例数据:
+```
+08:00-08:45 语文 王老师
+08:55-09:40 数学 李老师 (当前课,进度 60%)
+09:50-10:35 英语 张老师
+10:45-11:30 物理 赵老师
+14:00-14:45 化学 陈老师
+14:55-15:40 生物 刘老师
+```
+
+- [ ] **Step 2: 在浏览器中打开 Mock 验证效果**
+
+Run: `start mocks/class-schedule-mock.html`
+
+- [ ] **Step 3: 根据视觉效果调整 Mock 细节**
+
+调整间距、色值、字体大小、进度条样式等直到满意。
+
+---
+
+### Task 2: 创建科目配色服务
+
+**Files:**
+- Create: `LanMountainDesktop/Views/Components/SubjectColorService.cs`
+
+- [ ] **Step 1: 实现 SubjectColorService**
+
+```csharp
+using System;
+using Avalonia.Media;
+
+namespace LanMountainDesktop.Views.Components;
+
+internal static class SubjectColorService
+{
+ private static readonly (string Name, string Hex)[] Palette = [
+ ("语文", "#5B8FF9"),
+ ("数学", "#F6903D"),
+ ("英语", "#5AD8A6"),
+ ("物理", "#E8684A"),
+ ("化学", "#9270CA"),
+ ("生物", "#FF9845"),
+ ("历史", "#1E9493"),
+ ("地理", "#FF99C3"),
+ ("政治", "#7262FD"),
+ ("体育", "#78D3F8"),
+ ("音乐", "#F25E7E"),
+ ("美术", "#C2A1FD"),
+ ];
+
+ private static readonly string DefaultHex = "#8B95A5";
+
+ public static Color ResolveColor(string subjectName)
+ {
+ foreach (var (name, hex) in Palette)
+ {
+ if (subjectName.Contains(name, StringComparison.OrdinalIgnoreCase))
+ {
+ return Color.Parse(hex);
+ }
+ }
+
+ var hash = StableHash(subjectName);
+ var index = (int)(hash % (uint)Palette.Length);
+ return Color.Parse(Palette[index].Hex);
+ }
+
+ public static Color ResolveBackgroundColor(string subjectName, bool isCurrent, bool isNight)
+ {
+ var baseColor = ResolveColor(subjectName);
+ var alpha = isCurrent ? 0.18 : 0.08;
+ return new Color(
+ (byte)(alpha * 255),
+ baseColor.R,
+ baseColor.G,
+ baseColor.B);
+ }
+
+ public static Color ResolveForegroundColor(string subjectName, bool isNight)
+ {
+ var baseColor = ResolveColor(subjectName);
+ return isNight
+ ? new Color(0xFF, (byte)Math.Min(255, baseColor.R + 60), (byte)Math.Min(255, baseColor.G + 60), (byte)Math.Min(255, baseColor.B + 60))
+ : baseColor;
+ }
+
+ private static uint StableHash(string input)
+ {
+ uint hash = 5381;
+ foreach (var c in input)
+ {
+ hash = ((hash << 5) + hash) ^ (uint)c;
+ }
+ return hash;
+ }
+}
+```
+
+- [ ] **Step 2: 验证编译通过**
+
+Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug --no-restore`
+
+---
+
+### Task 3: 重构 ClassScheduleWidget XAML 布局
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml`
+
+- [ ] **Step 1: 重写 XAML 布局**
+
+新的 XAML 结构:
+- RootBorder 保持 `DesignCornerRadiusComponent`
+- 头部区域:日期(大号)+ 星期 + 课数 + 进度摘要
+- 课程列表区域:ScrollViewer 包裹 StackPanel
+- 每个 CourseItem 将在 code-behind 中构建为:Grid(时间列 + 卡片列)
+ - 时间列:StartTime / EndTime 垂直排列
+ - 卡片列:Border(科目色背景) > StackPanel(课程名 + 教师信息 + 进度条)
+
+XAML 只定义骨架,课程项仍由 code-behind 动态构建(因为需要科目配色和进度计算)。
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+### Task 4: 重构 ClassScheduleWidget 渲染逻辑
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs`
+
+- [ ] **Step 1: 扩展 CourseItemViewModel**
+
+在现有 record 中增加字段:
+
+```csharp
+private sealed record CourseItemViewModel(
+ string Name,
+ string TimeRange,
+ string Detail,
+ bool IsCurrent,
+ TimeSpan StartTime,
+ TimeSpan EndTime,
+ double Progress);
+```
+
+- [ ] **Step 2: 修改 BuildCourseItemViewModels 计算进度**
+
+在构建 ViewModel 时,对当前课程计算 Progress = (now - startTime) / (endTime - startTime)。
+
+- [ ] **Step 3: 重写 CreateSingleItemControl**
+
+新的课程项 UI 结构:
+
+```
+Grid (2列: 时间列 Auto + 卡片列 *)
+├── StackPanel (时间列)
+│ ├── TextBlock (开始时间, 如 "08:00")
+│ └── TextBlock (结束时间, 如 "08:45", 较淡)
+└── Border (卡片列, 科目色背景, 圆角 DesignCornerRadiusSm)
+ ├── 左侧强调竖条 (当前课显示, 3px宽, 科目色)
+ └── StackPanel
+ ├── TextBlock (课程名, 科目色前景, 加粗)
+ ├── TextBlock (教师/教室, 次要色)
+ └── ProgressBar (当前课显示, 科目色)
+```
+
+关键改动点:
+1. 移除圆点(Bullet),改用时间轴左侧时间标签
+2. 课程卡片使用 `SubjectColorService` 配色
+3. 当前课程卡片左侧显示强调竖条 + 底部进度条
+4. 课间区域用淡色分隔线标识
+5. 颜色使用语义资源键(`AdaptiveTextPrimaryBrush` 等),科目色通过 `SubjectColorService` 获取
+
+- [ ] **Step 4: 重写 ApplyAdaptiveLayout**
+
+更新自适应布局逻辑:
+- 头部日期/星期/课数徽章的字号和间距
+- 移除旧的圆点、文字栈相关计算
+- 新增时间列宽度、卡片圆角、进度条高度等计算
+- 使用 `ComponentChromeCornerRadiusHelper` 获取圆角 Token
+
+- [ ] **Step 5: 更新 IncrementalUpdateItems 和 IncrementalUpdateCurrentCourseHighlight**
+
+适配新的 UI 结构:
+- 更新进度条值
+- 更新科目色背景
+- 更新强调竖条可见性
+
+- [ ] **Step 6: 更新 RefreshSchedule 中的时间计算**
+
+在 `BuildCourseItemViewModels` 中传入 `StartTime`/`EndTime`/`Progress`。
+
+- [ ] **Step 7: 验证编译通过**
+
+Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug`
+
+---
+
+### Task 5: 验证与测试
+
+- [ ] **Step 1: 运行项目查看效果**
+
+Run: `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`
+
+- [ ] **Step 2: 运行相关测试**
+
+Run: `dotnet test LanMountainDesktop.slnx -c Debug`
+
+- [ ] **Step 3: 检查圆角规范合规**
+
+确认 RootBorder 使用 `DesignCornerRadiusComponent`,内部卡片使用 `DesignCornerRadiusSm`/`DesignCornerRadiusMd`,无硬编码圆角值。
+
+---
+
+## 假设与决策
+
+1. **科目配色**: 使用预定义调色板 + 哈希回退,不依赖 ClassIsland 数据中的科目颜色(因为 ClassIsland 不提供科目颜色字段)
+2. **进度条**: 仅当前课程显示进度条,非当前课程不显示
+3. **课间分隔**: 用 4px 间距 + 可选的淡色虚线分隔,不做复杂的课间休息区域
+4. **Mock 优先**: 先完成 HTML Mock 确认视觉效果,再实现 Avalonia 代码
+5. **编辑器不变**: ClassScheduleComponentEditor 不需要修改
+6. **数据层不变**: ClassIslandScheduleDataService 和 Models 不需要修改
+7. **接口兼容**: IDesktopComponentWidget、ITimeZoneAwareComponentWidget、IComponentPlacementContextAware 接口实现不变
+
+## 验证步骤
+
+1. HTML Mock 在浏览器中展示效果满意
+2. Avalonia 项目编译通过
+3. 运行项目,课程表组件显示新布局
+4. 亮色/暗色主题切换正常
+5. 当前课程高亮和进度条正常
+6. 科目配色稳定(同一科目每次显示颜色一致)
+7. 测试通过
diff --git a/.trae/documents/launcher-resx-i18n-plan.md b/.trae/documents/launcher-resx-i18n-plan.md
new file mode 100644
index 0000000..ad3dcd7
--- /dev/null
+++ b/.trae/documents/launcher-resx-i18n-plan.md
@@ -0,0 +1,850 @@
+# 启动器 RESX 多语言适配实施计划
+
+> **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:** 为 LanMountainDesktop.Launcher 引入 RESX 资源文件,实现启动器 UI 的多语言适配,消除所有硬编码中英文字符串。
+
+**Architecture:** 在 Launcher 项目中创建 RESX 资源文件体系(默认 zh-CN + en-US/ja-JP/ko-KR),通过 .NET 内置 `ResourceManager` 机制实现本地化。启动时从主应用 `settings.json` 读取 `LanguageCode` 字段设置 `CultureInfo.CurrentUICulture`,AXAML 中使用 `x:Static` 引用资源,C# 代码中通过 `Strings.ResourceName` 强类型访问。
+
+**Tech Stack:** .NET RESX 资源文件、Avalonia `x:Static` 标记扩展、`System.Globalization.CultureInfo`
+
+---
+
+## 现状分析
+
+### 问题概述
+
+1. **启动器完全没有本地化支持**:所有 UI 字符串硬编码,中英文混杂严重
+2. **纯英文窗口**:SplashWindow、ErrorWindow、MultiInstancePromptWindow、DataLocationPromptWindow、LoadingDetailsWindow
+3. **纯中文窗口**:OobeWindow、MigrationPromptWindow、UpdateWindow、ErrorDebugWindow、DevDebugWindow、PrivacyPolicyWindow
+4. **启动器不读取主应用语言设置**:没有 `LanguageCode` 相关代码
+5. **硬编码字符串总量约 180+ 条**,分布在 11 个 AXAML 视图和 11 个 C# code-behind 文件中
+
+### 方案选择:RESX vs JSON
+
+| 维度 | RESX(本方案) | JSON(主项目模式) |
+|------|---------------|-------------------|
+| 编译时安全 | ✅ 强类型 `Strings.KeyName` | ❌ 字符串键值 `L("key", "fallback")` |
+| AXAML 集成 | ✅ `x:Static` 直接引用 | ❌ 需 code-behind 赋值 |
+| 回退机制 | ✅ 内置(默认资源 → 特定文化) | ✅ 自定义 `fallback` 参数 |
+| 新增语言 | 需添加 RESX 文件并重新编译 | 仅添加 JSON 文件 |
+| AOT 兼容性 | ⚠️ 需额外配置 | ✅ 已验证 |
+| 与主项目一致性 | ❌ 不同模式 | ✅ 一致 |
+
+**选择 RESX 的理由**:启动器是独立轻量进程,不需要运行时语言切换;强类型访问减少拼写错误;`x:Static` 比 code-behind 赋值更清晰;RESX 的内置回退机制足够满足启动器需求。
+
+### AOT 兼容性说明
+
+Launcher 项目支持 Native AOT 发布。RESX 的 `ResourceManager` 依赖反射,需要:
+1. 在 `.csproj` 中添加 `` 确保资源不被修剪
+2. 在 AOT props 中添加 `TrimmerRootAssembly` 保留资源程序集
+3. 发布后进行 AOT 冒烟测试验证
+
+---
+
+## 文件结构规划
+
+### 新增文件
+
+| 文件 | 职责 |
+|------|------|
+| `Resources/Strings.resx` | 默认资源文件(zh-CN,回退资源) |
+| `Resources/Strings.en-US.resx` | 英语资源 |
+| `Resources/Strings.ja-JP.resx` | 日语资源 |
+| `Resources/Strings.ko-KR.resx` | 韩语资源 |
+| `Services/LanguagePreferenceService.cs` | 从 settings.json 读取 LanguageCode 并设置 CultureInfo |
+
+### 修改文件
+
+| 文件 | 改动内容 |
+|------|---------|
+| `LanMountainDesktop.Launcher.csproj` | 添加 RESX 嵌入资源配置 |
+| `LanMountainDesktop.Launcher.AOT.props` | 添加资源程序集修剪保留 |
+| `Program.cs` | 启动时调用语言偏好初始化 |
+| `Views/SplashWindow.axaml` | 替换硬编码字符串为 `x:Static` |
+| `Views/SplashWindow.axaml.cs` | 替换 C# 硬编码字符串为 `Strings.XXX` |
+| `Views/ErrorWindow.axaml` | 同上 |
+| `Views/ErrorWindow.axaml.cs` | 同上 |
+| `Views/MultiInstancePromptWindow.axaml` | 同上 |
+| `Views/MultiInstancePromptWindow.axaml.cs` | 同上 |
+| `Views/DataLocationPromptWindow.axaml` | 同上 |
+| `Views/DataLocationPromptWindow.axaml.cs` | 同上 |
+| `Views/LoadingDetailsWindow.axaml` | 同上 |
+| `Views/LoadingDetailsWindow.axaml.cs` | 同上 |
+| `Views/UpdateWindow.axaml` | 同上 |
+| `Views/UpdateWindow.axaml.cs` | 同上 |
+| `Views/ErrorDebugWindow.axaml` | 同上 |
+| `Views/ErrorDebugWindow.axaml.cs` | 同上 |
+| `Views/OobeWindow.axaml` | 同上 |
+| `Views/OobeWindow.axaml.cs` | 同上 |
+| `Views/MigrationPromptWindow.axaml` | 同上 |
+| `Views/MigrationPromptWindow.axaml.cs` | 同上 |
+| `Views/PrivacyPolicyWindow.axaml` | 同上 |
+| `Views/PrivacyPolicyWindow.axaml.cs` | 同上 |
+| `Views/DevDebugWindow.axaml` | 同上 |
+| `Views/DevDebugWindow.axaml.cs` | 同上 |
+| `Services/LauncherFlowCoordinator.cs` | 替换硬编码字符串 |
+| `App.axaml.cs` | 替换预览模式硬编码字符串 |
+
+---
+
+## RESX 键命名规范
+
+采用 `ViewName_ElementDescription` 模式,PascalCase 分隔:
+
+- 窗口标题:`Splash_Title`、`Error_Title`、`MultiInstance_Title`
+- 按钮文本:`Error_ButtonOpenLogs`、`Error_ButtonCopy`、`Error_ButtonRetry`
+- 状态文本:`Splash_StatusInitializing`、`Loading_StatusPreparing`
+- 描述文本:`DataLocation_DescSystemProfile`、`DataLocation_DescPortable`
+- OOBE 步骤:`Oobe_StepWelcomeTitle`、`Oobe_StepAppearanceTitle`
+
+---
+
+## 实施任务
+
+### Task 1: 创建 RESX 基础设施
+
+**Files:**
+- Create: `LanMountainDesktop.Launcher/Resources/Strings.resx`
+- Create: `LanMountainDesktop.Launcher/Resources/Strings.en-US.resx`
+- Create: `LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx`
+- Create: `LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx`
+- Modify: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
+- Modify: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props`
+
+- [ ] **Step 1: 创建默认 RESX 文件(zh-CN 回退资源)**
+
+创建 `Resources/Strings.resx`,包含所有 180+ 条字符串的中文翻译。此文件同时作为回退资源和中文资源。
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+
+
+ 阑山桌面
+ 阑山桌面
+ 正在初始化...
+ [调试模式] 启动画面预览
+
+
+ 阑山桌面
+ 启动器无法确认启动状态
+ 阑山桌面未达到预期的启动状态。
+ 启动恢复
+ 您可以检查日志、等待当前进程或激活正在运行的桌面实例。
+ 诊断详情
+ 打开日志
+ 复制
+ 等待
+ 退出
+ 重试
+ 激活
+ [调试] 启动器错误
+ 启动器找不到桌面可执行文件
+ 在调试模式下选择另一个可执行文件、检查日志,或在修复部署路径后重试。
+ 检查日志后重试,等待上一次启动尝试完全结束。
+ 检查日志或退出。旧进程仍在运行时,启动器不会创建新的桌面进程。
+ 启动仍在进行中
+ 桌面进程仍在运行,启动器不会启动第二个实例。
+
+
+ 阑山桌面
+ 阑山桌面已在运行
+ 启动器检测到已存在的桌面实例,未启动新进程。
+ 重复启动
+ 您当前的设置为显示此提示而不自动打开桌面。
+ 未创建第二个主进程。
+ 复制
+ 关闭
+ 打开桌面
+ 现有主进程 PID: {0}\nShell 状态: {1}\n未创建第二个主进程。
+
+
+ 选择数据保存位置
+ 选择数据保存位置
+ 选择启动器和桌面数据的存储位置。您可以稍后在设置中更改。
+ 应用目录不可写入
+ 当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。
+ 保存在系统用户目录(推荐)
+ 数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。
+ 保存在应用安装目录(便携模式)
+ 适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。
+ 取消
+ 确认
+ 检测到已有的系统数据。选择便携模式将自动迁移当前数据。
+
+
+ 阑山桌面 - 加载详情
+ 正在启动阑山桌面
+ 正在初始化...
+ 正在准备组件
+ 加载项目
+ 完成
+ 加载时发生错误。
+ 详情
+ 取消
+ 准备就绪
+ 正在加载插件...
+ 正在加载组件...
+ 正在加载资源...
+ 正在加载数据...
+ 正在下载...
+ 正在处理...
+ 完成
+ 插件
+ 组件
+ 资源
+ 数据
+ 网络
+ 设置
+ 系统
+ 其他
+
+
+ 阑山桌面 - 更新
+ 阑山桌面
+ 更新
+ 正在更新,请稍候...
+ 更新完成
+ 更新失败
+ 更新过程中发生错误
+ [调试模式] 更新页面
+ 预览更新进度界面
+
+
+ 调试模式
+ 调试设置
+ 开发模式
+ 启用后自动扫描开发目录
+ 开
+ 关
+ 应用路径
+ 未选择
+ 浏览...
+ 此功能仅供开发人员使用
+ 取消
+ 确定
+ 选择阑山桌面主程序可执行文件
+
+
+ 欢迎使用阑山桌面
+ 欢迎使用阑山桌面
+ 你的桌面,不止一面
+ 开始使用
+ 个性化你的桌面
+ 选择你喜欢的主题样式,可随时在设置中更改
+ 外观模式
+ 浅色模式
+ 深色模式
+ 主题色
+ 莫奈取色来源
+ 从桌面壁纸取色
+ 自定义图片取色
+ 不使用莫奈取色
+ 选择数据保存位置
+ 保存在系统用户目录(推荐)
+ 数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。
+ 保存在应用安装目录(便携模式)
+ 适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。
+ 无法保存到应用目录
+ 当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。
+ 启动与展示
+ 在任务栏显示主桌面窗口
+ 以滑动方式显示主窗口
+ 启动时使用淡入过渡
+ 融合桌面与弹入手势
+ 登录 Windows 时自动启动阑山桌面
+ 信息与隐私
+ 发送匿名崩溃报告
+ 发送匿名使用统计
+ 隐私追踪 ID
+ 同意
+ 《阑山桌面遥测隐私数据收集协议》
+ 返回
+ 下一步
+ 欢迎使用阑山桌面
+ 你的桌面,不止一面
+
+
+ 阑山桌面 - 版本迁移
+ 检测到旧版本
+ 检测到您的系统中安装了旧版本的阑山桌面(0.8.4)...
+ 版本:
+ 位置:
+ 类型:
+ 安装版
+ 卸载旧版本不会影响新版本的使用,您的个人数据将保留。
+ 查看位置
+ 暂不处理
+ 卸载旧版本
+
+
+ 阑山桌面遥测隐私数据收集协议
+ 阑山桌面遥测隐私数据收集协议
+ 请仔细阅读以下协议内容,了解我们如何收集、使用和保护您的数据
+ 关闭
+
+
+ 开发调试窗口
+ 启动画面
+ 错误页面
+ 更新页面
+ OOBE页面
+ 数据位置选择
+ 启用功能
+ 打开
+ 全部设为查看模式
+ 全部设为功能模式
+ 关闭
+
+
+ 设备较慢,仍在启动,请稍候。
+ 桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。
+
+
+ 正在初始化...
+ 正在检查更新...
+ 正在检查插件...
+ 正在启动主程序...
+ 准备就绪
+ [预览] 这是启动器错误窗口预览。
+ 正在处理 {0}...
+ 正在连接到活跃的启动器...
+
+```
+
+- [ ] **Step 2: 创建 en-US RESX 文件**
+
+创建 `Resources/Strings.en-US.resx`,包含所有字符串的英文翻译。结构与默认文件相同,仅 `` 内容为英文。
+
+```xml
+
+LanMountain Desktop
+LanMountain Desktop
+Initializing...
+Launcher could not confirm startup
+LanMountain Desktop did not reach the expected startup state.
+
+```
+
+- [ ] **Step 3: 创建 ja-JP RESX 文件**
+
+创建 `Resources/Strings.ja-JP.resx`,包含所有字符串的日语翻译。
+
+- [ ] **Step 4: 创建 ko-KR RESX 文件**
+
+创建 `Resources/Strings.ko-KR.resx`,包含所有字符串的韩语翻译。
+
+- [ ] **Step 5: 修改 .csproj 添加 RESX 配置**
+
+在 `LanMountainDesktop.Launcher.csproj` 的 `` 中添加:
+
+```xml
+
+
+ PublicResXFileCodeGenerator
+ Strings.Designer.cs
+
+
+```
+
+注意:使用 `PublicResXFileCodeGenerator` 而非 `ResXFileCodeGenerator`,生成 `public` 类以便 AXAML 的 `x:Static` 可以访问。
+
+- [ ] **Step 6: 修改 AOT props 添加资源程序集保留**
+
+在 `LanMountainDesktop.Launcher.AOT.props` 的 AOT 修剪配置 `` 中添加:
+
+```xml
+
+```
+
+- [ ] **Step 7: 运行构建验证 RESX 生成**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功,`Resources/Strings.Designer.cs` 自动生成
+
+---
+
+### Task 2: 创建语言偏好服务
+
+**Files:**
+- Create: `LanMountainDesktop.Launcher/Services/LanguagePreferenceService.cs`
+- Modify: `LanMountainDesktop.Launcher/Program.cs`
+
+- [ ] **Step 1: 创建 LanguagePreferenceService**
+
+```csharp
+using System.Globalization;
+using System.Text.Json.Nodes;
+
+namespace LanMountainDesktop.Launcher.Services;
+
+internal static class LanguagePreferenceService
+{
+ public static string ResolveLanguageCode(string appRoot)
+ {
+ try
+ {
+ var dataLocationResolver = new DataLocationResolver(appRoot);
+ var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataLocationResolver.ResolveDataRoot());
+ if (!File.Exists(settingsPath))
+ {
+ return "zh-CN";
+ }
+
+ var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject();
+ if (root is not null &&
+ root.TryGetPropertyValue("LanguageCode", out var node) &&
+ node is JsonValue value &&
+ value.TryGetValue(out var code) &&
+ !string.IsNullOrWhiteSpace(code))
+ {
+ return NormalizeLanguageCode(code);
+ }
+ }
+ catch
+ {
+ }
+
+ return "zh-CN";
+ }
+
+ public static void ApplyLanguage(string languageCode)
+ {
+ var normalized = NormalizeLanguageCode(languageCode);
+ var culture = CultureInfo.GetCultureInfo(normalized);
+ CultureInfo.DefaultThreadCurrentCulture = culture;
+ CultureInfo.DefaultThreadCurrentUICulture = culture;
+ Thread.CurrentThread.CurrentCulture = culture;
+ Thread.CurrentThread.CurrentUICulture = culture;
+ }
+
+ private static string NormalizeLanguageCode(string code)
+ {
+ return code.ToLowerInvariant() switch
+ {
+ "en-us" or "en" => "en-US",
+ "ja-jp" or "ja" => "ja-JP",
+ "ko-kr" or "ko" => "ko-KR",
+ _ => "zh-CN"
+ };
+ }
+}
+```
+
+- [ ] **Step 2: 在 Program.cs 中调用语言初始化**
+
+在 `Program.Main` 方法中,`BuildAvaloniaApp().StartWithClassicDesktopLifetime(args)` 之前添加语言初始化:
+
+```csharp
+var appRoot = Commands.ResolveAppRoot(commandContext);
+var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot);
+LanguagePreferenceService.ApplyLanguage(languageCode);
+```
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 3: 替换 SplashWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/SplashWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs`
+
+- [ ] **Step 1: 在 SplashWindow.axaml 中添加 RESX 命名空间并替换字符串**
+
+在 `` 标签添加命名空间:
+```xml
+xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources"
+```
+
+替换硬编码字符串:
+- `Title="LanMountain Desktop"` → `Title="{x:Static res:Strings.Splash_Title}"`
+- `Text="LanMountain Desktop"` (AppNameText) → `Text="{x:Static res:Strings.Splash_AppName}"`
+- `Text="Initializing..."` (StatusText) → `Text="{x:Static res:Strings.Splash_StatusInitializing}"`
+
+注意:`VersionText` 的 `Text="0.0.0-dev (Administrate)"` 是动态设置的占位文本,保留原样(由 code-behind `SetVersionInfo` 方法设置)。
+
+- [ ] **Step 2: 在 SplashWindow.axaml.cs 中替换 C# 硬编码字符串**
+
+将 `"[Debug Mode] Splash Preview"` 替换为 `Strings.Splash_DebugPreview`。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 4: 替换 ErrorWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/ErrorWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs`
+
+- [ ] **Step 1: 在 ErrorWindow.axaml 中添加 RESX 命名空间并替换字符串**
+
+添加命名空间 `xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources"`
+
+AXAML 替换:
+- `Title="LanMountain Desktop"` → `Title="{x:Static res:Strings.Error_Title}"`
+- `Text="Launcher could not confirm startup"` → `Text="{x:Static res:Strings.Error_TitleCannotConfirm}"`
+- `Text="LanMountain Desktop did not reach..."` → `Text="{x:Static res:Strings.Error_MessageNotReached}"`
+- `Title="Startup recovery"` → `Title="{x:Static res:Strings.Error_SuggestionTitle}"`
+- `Message="You can inspect logs..."` → `Message="{x:Static res:Strings.Error_SuggestionMessage}"`
+- `Header="Diagnostic details"` → `Header="{x:Static res:Strings.Error_DiagnosticHeader}"`
+- `Text="Open Logs"` → `Text="{x:Static res:Strings.Error_ButtonOpenLogs}"`
+- `Text="Copy"` → `Text="{x:Static res:Strings.Error_ButtonCopy}"`
+- `Content="Wait"` → `Content="{x:Static res:Strings.Error_ButtonWait}"`
+- `Text="Exit"` → `Text="{x:Static res:Strings.Error_ButtonExit}"`
+- `Content="Retry"` → `Content="{x:Static res:Strings.Error_ButtonRetry}"`
+
+- [ ] **Step 2: 在 ErrorWindow.axaml.cs 中替换 C# 硬编码字符串**
+
+将所有硬编码字符串替换为 `Strings.XXX` 调用:
+- `"LanMountain Desktop did not reach..."` → `Strings.Error_MessageNotReached`
+- `"[Debug] Launcher error"` → `Strings.Error_DebugTitle`
+- `"Launcher could not find the desktop executable"` → `Strings.Error_HostNotFoundTitle`
+- `"Pick another executable..."` → `Strings.Error_HostNotFoundMessage`
+- `"Launcher could not confirm startup"` → `Strings.Error_TitleCannotConfirm`
+- `"Inspect logs, then retry..."` → `Strings.Error_GenericMessage`
+- `"Inspect logs or exit..."` → `Strings.Error_RunningHostMessage`
+- `"Retry"` → `Strings.Error_ButtonRetry`
+- `"Activate"` → `Strings.Error_ButtonActivate`
+- `"Wait"` → `Strings.Error_ButtonWait`
+- `"Startup is still pending"` → `Strings.Error_PendingTitle`
+- `"The desktop process is still running..."` → `Strings.Error_PendingMessage`
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 5: 替换 MultiInstancePromptWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs`
+
+- [ ] **Step 1: 在 MultiInstancePromptWindow.axaml 中替换字符串**
+
+添加命名空间,替换:
+- `Title="LanMountain Desktop"` → `Title="{x:Static res:Strings.MultiInstance_Title}"`
+- `Text="LanMountain Desktop is already running"` → `Text="{x:Static res:Strings.MultiInstance_AlreadyRunning}"`
+- `Text="Launcher found an existing..."` → `Text="{x:Static res:Strings.MultiInstance_AlreadyRunningMessage}"`
+- `Title="Repeated launch"` → `Title="{x:Static res:Strings.MultiInstance_RepeatedLaunchTitle}"`
+- `Message="Your current setting..."` → `Message="{x:Static res:Strings.MultiInstance_RepeatedLaunchMessage}"`
+- `Text="No second Host process..."` → `Text="{x:Static res:Strings.MultiInstance_NoSecondProcess}"`
+- `Text="Copy"` → `Text="{x:Static res:Strings.MultiInstance_ButtonCopy}"`
+- `Text="Close"` → `Text="{x:Static res:Strings.MultiInstance_ButtonClose}"`
+- `Text="Open desktop"` → `Text="{x:Static res:Strings.MultiInstance_ButtonOpenDesktop}"`
+
+- [ ] **Step 2: 在 MultiInstancePromptWindow.axaml.cs 中替换 C# 硬编码字符串**
+
+将格式化字符串替换为 `string.Format(Strings.MultiInstance_DetailsFormat, processId, shellState)` 等。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 6: 替换 DataLocationPromptWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs`
+
+- [ ] **Step 1: 在 DataLocationPromptWindow.axaml 中替换字符串**
+
+替换所有 12 个硬编码字符串为 `x:Static` 引用。
+
+- [ ] **Step 2: 在 DataLocationPromptWindow.axaml.cs 中替换 C# 硬编码字符串**
+
+将 `"Existing system data was detected..."` 替换为 `Strings.DataLocation_MigrateWarning`。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 7: 替换 LoadingDetailsWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs`
+
+- [ ] **Step 1: 在 LoadingDetailsWindow.axaml 中替换字符串**
+
+替换所有硬编码字符串为 `x:Static` 引用。
+
+- [ ] **Step 2: 在 LoadingDetailsWindow.axaml.cs 中替换 C# 硬编码字符串**
+
+替换 `GetStageDescription`、`GetItemDescription`、`GetTypeLabel` 方法中的硬编码字符串为 `Strings.XXX` 调用。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 8: 替换 UpdateWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/UpdateWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs`
+
+- [ ] **Step 1: 在 UpdateWindow.axaml 中替换字符串**
+
+替换 `"Update"` 为 `x:Static res:Strings.Update_StatusUpdate`。
+
+- [ ] **Step 2: 在 UpdateWindow.axaml.cs 中替换 C# 硬编码字符串**
+
+替换 `"更新完成"`、`"更新失败"`、`"更新过程中发生错误"`、`"[调试模式] 更新页面"`、`"预览更新进度界面"` 为 `Strings.XXX` 调用。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 9: 替换 ErrorDebugWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs`
+
+- [ ] **Step 1: 在 ErrorDebugWindow.axaml 中替换字符串**
+
+该窗口已使用中文,替换所有硬编码中文字符串为 `x:Static` 引用。
+
+- [ ] **Step 2: 在 ErrorDebugWindow.axaml.cs 中替换 C# 硬编码字符串**
+
+替换 `"Select LanMountainDesktop host executable"` 和 `"Not selected"` 为 `Strings.DebugDebug_SelectExeDialog` 和 `Strings.DebugDebug_NotSelected`。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 10: 替换 OobeWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/OobeWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs`
+
+这是最大的单个任务,OobeWindow 有约 42 个硬编码字符串。
+
+- [ ] **Step 1: 在 OobeWindow.axaml 中替换字符串**
+
+添加命名空间,逐个替换所有硬编码中文字符串为 `x:Static` 引用。包括:
+- 窗口标题、欢迎页文本
+- 外观设置页文本
+- 数据位置页文本
+- 启动展示页文本
+- 隐私页文本
+- 完成页文本
+- 导航按钮文本
+
+- [ ] **Step 2: 在 OobeWindow.axaml.cs 中替换 C# 硬编码字符串(如有)**
+
+检查 code-behind 中是否有动态设置的硬编码字符串并替换。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 11: 替换 MigrationPromptWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs`
+
+- [ ] **Step 1: 在 MigrationPromptWindow.axaml 中替换字符串**
+
+替换所有硬编码中文字符串为 `x:Static` 引用。
+
+- [ ] **Step 2: 在 MigrationPromptWindow.axaml.cs 中替换 C# 硬编码字符串(如有)**
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 12: 替换 PrivacyPolicyWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml.cs`
+
+- [ ] **Step 1: 在 PrivacyPolicyWindow.axaml 中替换字符串**
+
+替换标题、描述、关闭按钮等硬编码字符串。
+
+- [ ] **Step 2: 在 PrivacyPolicyWindow.axaml.cs 中处理隐私政策正文**
+
+隐私政策正文(约 80 行 Markdown)目前硬编码在 C# 中。考虑:
+- 方案 A:将 Markdown 正文也放入 RESX(支持多语言隐私政策)
+- 方案 B:保留 Markdown 正文在 C# 中,仅替换窗口标题和按钮
+
+推荐方案 A,将隐私政策 Markdown 正文放入 RESX 的 `Privacy_PolicyContent` 键中。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 13: 替换 DevDebugWindow 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml`
+- Modify: `LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs`
+
+- [ ] **Step 1: 在 DevDebugWindow.axaml 中替换字符串**
+
+替换所有硬编码中文字符串为 `x:Static` 引用。
+
+- [ ] **Step 2: 在 DevDebugWindow.axaml.cs 中替换 C# 硬编码字符串(如有)**
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 14: 替换 LauncherFlowCoordinator 和 App.axaml.cs 硬编码字符串
+
+**Files:**
+- Modify: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
+- Modify: `LanMountainDesktop.Launcher/App.axaml.cs`
+
+- [ ] **Step 1: 在 LauncherFlowCoordinator.cs 中替换字符串**
+
+替换:
+- `"设备较慢,仍在启动,请稍候。"` → `Strings.Coordinator_SlowDeviceMessage`
+- `"桌面主进程仍在运行..."` → `Strings.Coordinator_RunningHostMessage`
+
+- [ ] **Step 2: 在 App.axaml.cs 中替换预览模式字符串**
+
+替换 `SimulateSplashPreviewAsync` 中的硬编码消息数组:
+```csharp
+var messages = new[] { Strings.Preview_SplashInitializing, Strings.Preview_SplashCheckingUpdates, Strings.Preview_SplashCheckingPlugins, Strings.Preview_SplashLaunchingHost, Strings.Preview_SplashReady };
+```
+
+替换 `HandlePreviewCommand` 中的 `"[Preview] This is the launcher error window preview."` → `Strings.Preview_ErrorMessage`
+
+替换 `RunApplyUpdateWithWindowAsync` 中的硬编码字符串:
+- `"Verifying update..."` → 使用 RESX 键
+- `"Applying plugin upgrades..."` → 使用 RESX 键
+- `"Cleaning up old deployments..."` → 使用 RESX 键
+
+替换 `SimulateUpdatePreviewAsync` 中的 `$"Processing {stages[i]}..."` → `string.Format(Strings.Preview_UpdateProcessing, stages[i])`
+
+替换 `AttachToExistingCoordinatorAsync` 中的 `"Connecting to the active launcher..."` → `Strings.Preview_ActivationConnecting`
+
+- [ ] **Step 3: 构建验证**
+
+Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug`
+Expected: 构建成功
+
+---
+
+### Task 15: 完整构建和运行验证
+
+**Files:** 无新增/修改
+
+- [ ] **Step 1: 完整解决方案构建**
+
+Run: `dotnet build LanMountainDesktop.slnx -c Debug`
+Expected: 构建成功,无错误
+
+- [ ] **Step 2: 运行启动器预览命令验证中文**
+
+Run: `dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- preview-splash`
+Expected: 启动画面显示中文
+
+- [ ] **Step 3: 验证英文模式**
+
+临时将 `LanguagePreferenceService.ResolveLanguageCode` 返回 `"en-US"` 后运行预览命令,验证英文显示。
+
+- [ ] **Step 4: 运行测试**
+
+Run: `dotnet test LanMountainDesktop.slnx -c Debug`
+Expected: 所有测试通过
+
+---
+
+### Task 16: AOT 发布冒烟测试
+
+**Files:** 无新增/修改
+
+- [ ] **Step 1: AOT 发布测试**
+
+Run: `dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Release -r win-x64 /p:PublishAot=true`
+Expected: 发布成功
+
+- [ ] **Step 2: 运行 AOT 发布产物验证**
+
+运行发布后的可执行文件,验证 RESX 资源正确加载。
+
+---
+
+## 实施顺序建议
+
+1. **Task 1** (RESX 基础设施) → **Task 2** (语言偏好服务) — 必须首先完成
+2. **Task 3-9** (英文窗口) — 优先处理,解决用户提出的"只有英文"问题
+3. **Task 10-13** (中文窗口) — 次优先,完成完整 i18n 覆盖
+4. **Task 14** (服务层和 App) — 与 Task 3-13 并行或随后
+5. **Task 15-16** (验证) — 最后执行
+
+## 风险与注意事项
+
+1. **AOT 兼容性**:`ResourceManager` 在 Native AOT 下可能需要额外配置。如果 AOT 发布失败,需要添加 `DynamicDependency` 属性或使用 `System.Resources.Extensions` 包的源生成器。
+2. **OOBE 首次运行**:OOBE 在首次运行时 `settings.json` 不存在,此时 `LanguagePreferenceService` 会回退到 `zh-CN`。这是合理的行为。
+3. **`x:Static` 与 Avalonia CompiledBindings**:项目启用了 `AvaloniaUseCompiledBindingsByDefault`,需要确认 `x:Static` 在编译绑定模式下正常工作。如有问题,可在特定 AXAML 文件中添加 `x:CompileBindings="False"`。
+4. **RESX Designer.cs 生成**:确保 `.csproj` 中使用 `PublicResXFileCodeGenerator` 生成 `public` 类,否则 `x:Static` 无法访问。
+5. **隐私政策多语言**:隐私政策 Markdown 正文较长,放入 RESX 可能影响可读性。可考虑保留在 C# 中或使用独立资源文件。
diff --git a/.trae/documents/launcher_improved_plan_v2.md b/.trae/documents/launcher_improved_plan_v2.md
index b9874c5..0541a50 100644
--- a/.trae/documents/launcher_improved_plan_v2.md
+++ b/.trae/documents/launcher_improved_plan_v2.md
@@ -154,7 +154,7 @@
│ │
│ 方案 2: 命名管道(推荐用于进度报告) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
-│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
+│ │ [历史方案] Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
│ │ 主程序连接并发送进度消息 │ │
│ │ │ │
│ │ 消息格式: JSON │ │
@@ -289,7 +289,7 @@ public static class LauncherIpcConstants
#### 4. 实现 IPC 服务端
-**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
+**历史方案,已废弃**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
```csharp
using System.IO.Pipes;
@@ -428,7 +428,7 @@ public async Task RunAsync()
#### 6. 实现 IPC 客户端
-**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
+**历史方案,已废弃**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
```csharp
using System.IO.Pipes;
@@ -672,8 +672,8 @@ public class UpdateInstallationService
### 新增文件
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
-2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
-3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
+2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - 历史启动进度 IPC 服务端,已由公共 IPC 通知替代
+3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - 历史启动进度 IPC 客户端,已由公共 IPC 通知替代
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
### 删除文件
@@ -715,3 +715,11 @@ public class UpdateInstallationService
- [ ] GitHub Actions 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常
+
+## 2026 Multi-instance Policy Update
+
+- The old launcher progress pipe is historical only; current startup progress uses public IPC.
+- Launcher now reads Host `settings.json` for `MultiInstanceLaunchBehavior` before normal launch.
+- Existing Host behavior is policy-driven: restart app, open desktop silently, prompt only, or notify and open desktop.
+- Host no longer owns the single-instance listener or already-running prompt; repeated-launch policy lives in Launcher.
+- The repeated-launch prompt is a Fluent Launcher window; Host public IPC only exposes execution actions such as activate, restart, and exit.
diff --git a/.trae/documents/update-settings-redesign.md b/.trae/documents/update-settings-redesign.md
new file mode 100644
index 0000000..c049ec5
--- /dev/null
+++ b/.trae/documents/update-settings-redesign.md
@@ -0,0 +1,212 @@
+# 更新设置界面重设计实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将更新设置页面从丑陋的卡片堆叠布局重设计为遵循 Fluent Design 的 FASettingsExpander 列表布局,与项目其他设置页面保持视觉一致性。
+
+**Architecture:** 移除所有 `Border.settings-section-card` 包裹,改用 `FASettingsExpander` + `IconText` 分节标题 + `Separator` 分隔线的统一模式。操作按钮改为仅显示当前可用操作。版本信息改为 `FASettingsExpanderItem` 行项目展示。ViewModel 层新增 `ActiveActions` 计算属性来驱动按钮可见性。
+
+**Tech Stack:** Avalonia UI 11, FluentAvalonia 2.x, CommunityToolkit.Mvvm
+
+---
+
+## 当前状态分析
+
+### 现有文件
+
+| 文件 | 职责 |
+|------|------|
+| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` | 更新页面 AXAML 布局 |
+| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs` | 代码隐藏 |
+| `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` | 视图模型 |
+| `LanMountainDesktop/Styles/SettingsCardStyles.axaml` | 通用设置样式 |
+| `LanMountainDesktop/Controls/IconText.axaml(.cs)` | 分节标题控件 |
+| `LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs` | UpdatePhase 枚举和扩展方法 |
+
+### 核心问题
+
+1. **4 个 `Border.settings-section-card` 卡片**:状态卡、版本信息卡、进度卡、操作卡,每个都带边框+阴影+圆角,视觉零碎
+2. **FAInfoBar 嵌套在卡片内**:冗余的容器层级
+3. **7 个按钮 3×3 网格**:大量按钮在当前阶段不可用但仍然占据空间
+4. **与其他设置页面风格不一致**:GeneralSettingsPage、AppearanceSettingsPage 等全部使用 `FASettingsExpander` 列表
+
+### 参考基准
+
+- [GeneralSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml):`IconText` 分节标题 → `FASettingsExpander` 列表 → `Separator` 分隔
+- [AppearanceSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml):同上模式
+- [AboutSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml):`FAInfoBar` 用于静态信息展示
+- Windows 11 设置 > Windows Update:顶部状态区 + 进度条 + 操作按钮,下方展开区展示详情
+
+---
+
+## 设计决策
+
+| 决策项 | 选择 | 理由 |
+|--------|------|------|
+| 布局模式 | FASettingsExpander 列表 | 与其他设置页面统一,Fluent Design 原生控件 |
+| 按钮策略 | 仅显示可用操作 | 简洁、不混乱,Windows 11 更新页面也是此模式 |
+| 版本信息 | FASettingsExpanderItem 行项目 | 每行一个信息,干净可扫描 |
+| 进度展示 | 内嵌在状态 Expander 内 | 进度是状态的一部分,不应独立成卡 |
+| 偏好设置 | 保留 FASettingsExpander | 已经是正确模式,微调即可 |
+
+---
+
+## 新布局结构
+
+```
+ScrollViewer
+└── StackPanel (settings-page-container settings-page-animated)
+ ├── TextBlock (settings-section-title: "更新")
+ ├── TextBlock (settings-section-description: 描述文字)
+ │
+ ├── IconText (Icon="ArrowSync", Text="更新状态")
+ │
+ ├── FASettingsExpander "检查更新" (IsClickEnabled=True, Command=CheckCommand)
+ │ ├── IconSource: ArrowSync 图标
+ │ └── Footer: Button "检查更新" (仅 CanCheck 时可见)
+ │
+ ├── FASettingsExpander "更新进度" (IsVisible=IsBusy||IsProgressVisible||IsPaused)
+ │ ├── IconSource: FAProgressRing / 对应阶段图标
+ │ ├── Footer: PhaseText + ProgressFraction
+ │ └── FASettingsExpanderItem
+ │ ├── ProgressBar (ProgressFraction)
+ │ ├── ProgressDetail 文字
+ │ └── 操作按钮行 (仅可用操作)
+ │ ├── Button "下载" (CanDownload)
+ │ ├── Button "安装" (CanInstall)
+ │ ├── Button "暂停" (CanPause)
+ │ ├── Button "继续" (CanResume)
+ │ ├── Button "回滚" (CanRollback)
+ │ └── Button "取消" (CanCancel)
+ │
+ ├── FASettingsExpander "暂停" (IsVisible=IsPaused)
+ │ └── FAInfoBar (PausedBadgeText + PausedHintText)
+ │
+ ├── Separator (settings-separator)
+ │
+ ├── IconText (Icon="Info", Text="版本信息")
+ │
+ ├── FASettingsExpander "当前版本" (IsClickEnabled=False)
+ │ ├── IconSource: 版本图标
+ │ └── Footer: CurrentVersionText
+ │
+ ├── FASettingsExpander "最新版本" (IsClickEnabled=False)
+ │ ├── IconSource: 下载图标
+ │ └── Footer: LatestVersionText (或 "已是最新")
+ │
+ ├── FASettingsExpander "发布时间" (IsClickEnabled=False)
+ │ ├── IconSource: 日历图标
+ │ └── Footer: PublishedAtText
+ │
+ ├── FASettingsExpander "上次检查" (IsClickEnabled=False)
+ │ ├── IconSource: 时钟图标
+ │ └── Footer: LastCheckedText
+ │
+ ├── FASettingsExpander "更新类型" (IsClickEnabled=False)
+ │ ├── IconSource: 标签图标
+ │ └── Footer: UpdateTypeText
+ │
+ ├── Separator (settings-separator)
+ │
+ ├── IconText (Icon="Settings", Text="更新偏好")
+ │
+ └── FASettingsExpander "更新偏好" (IsExpanded=True)
+ ├── IconSource: 设置齿轮图标
+ ├── FASettingsExpanderItem "更新频道"
+ │ └── Footer: ComboBox (stable/preview)
+ ├── FASettingsExpanderItem "下载源"
+ │ └── Footer: ComboBox (plonds/github/proxy)
+ ├── FASettingsExpanderItem "更新模式"
+ │ └── Footer: ComboBox (manual/confirm/silent)
+ └── FASettingsExpanderItem "下载线程数"
+ └── Footer: Slider + TextBlock
+```
+
+---
+
+## Proposed Changes
+
+### Task 1: 重写 UpdateSettingsPage.axaml 布局
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml`
+
+**What:** 完全重写 AXAML,将 4 个 `Border.settings-section-card` 替换为 `FASettingsExpander` 列表布局。
+
+**Key changes:**
+1. 移除所有 `Border.settings-section-card` 包裹
+2. 使用 `controls:IconText` 做分节标题(与 GeneralSettingsPage 一致)
+3. 状态区域:`FASettingsExpander` + `IsClickEnabled=True` + `Command=CheckCommand`,Footer 放检查按钮
+4. 进度区域:`FASettingsExpander` 内嵌 ProgressBar + 操作按钮,仅 `IsBusy||IsProgressVisible||IsPaused` 时可见
+5. 版本信息:每个字段一个 `FASettingsExpander`,Footer 直接显示值(参考 Windows 11 更新页面的行项目模式)
+6. 偏好设置:保留 `FASettingsExpander` + `FASettingsExpanderItem` 模式,但将 TextBox 改为 ComboBox(更符合 Fluent 规范)
+7. 使用 `Separator classes="settings-separator"` 分隔三大区域
+
+**Why:** 与项目其他设置页面统一风格,遵循 Fluent Design,消除卡片堆叠的视觉噪音。
+
+**How:**
+- 参照 GeneralSettingsPage.axaml 的布局模式
+- 参照 AppearanceSettingsPage.axaml 的 FASettingsExpander 使用方式
+- 参照 AboutSettingsPage.axaml 的 FAInfoBar 使用方式
+
+### Task 2: 更新 ViewModel — 添加 ComboBox 数据源和按钮可见性属性
+
+**Files:**
+- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs`
+
+**What:**
+1. 将更新频道、下载源、更新模式从 `TextBox` 绑定改为 `ComboBox` 绑定,添加 `ObservableCollection` 类型的数据源属性
+2. 添加 `IsProgressSectionVisible` 计算属性(`IsBusy || IsProgressVisible || IsPaused`)
+3. 添加 `IsUpdateAvailableSectionVisible` 计算属性(`IsUpdateAvailable`)
+4. 添加 `IsStatusInfoVisible` 计算属性(有 StatusMessage 且非空闲时)
+5. 移除不再需要的独立按钮文本属性(CheckButtonText 保留,其他按钮文本属性保留但仅在可见时使用)
+
+**Why:** ComboBox 比 TextBox 更适合有限选项的输入,且与 GeneralSettingsPage 的模式一致。按钮可见性属性让 AXAML 可以用 `IsVisible` 绑定控制按钮显示。
+
+**How:**
+- 参考 GeneralSettingsPageViewModel 中 SelectionOption 的使用方式
+- 在 `OnCurrentPhaseChanged` 中触发新属性的 OnPropertyChanged
+
+### Task 3: 将偏好设置 TextBox 替换为 ComboBox
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` (在 Task 1 中一并完成)
+- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` (在 Task 2 中一并完成)
+
+**What:** 将更新频道、下载源、更新模式三个 `TextBox` 替换为 `ComboBox`,使用 `SelectionOption` 数据模板。
+
+**Why:** 有限选项应使用 ComboBox 而非自由文本输入,这是 Fluent Design 的基本规范,也与 GeneralSettingsPage 中的语言/时区选择一致。
+
+### Task 4: 构建验证
+
+**Files:**
+- 无新文件
+
+**What:** 运行 `dotnet build` 确保编译通过,检查 AXAML 绑定是否正确。
+
+---
+
+## Assumptions & Decisions
+
+1. **不修改 UpdateOrchestrator 和 UpdateState** — 只改 UI 层和 ViewModel 的展示逻辑,不改底层更新引擎
+2. **不修改 SettingsCardStyles.axaml** — 通用样式保持不变,移除的是 UpdateSettingsPage 对它的使用
+3. **保留所有 ViewModel 属性** — 即使某些属性在新布局中不再直接使用(如独立的 ActionsTitle),也保留以避免破坏本地化系统
+4. **ComboBox 选项硬编码在 ViewModel** — 参考 GeneralSettingsPageViewModel 的 SelectionOption 模式
+5. **进度区域在空闲时隐藏** — 不显示空的进度条,只在有活动时展示
+6. **FAInfoBar 仅用于暂停/错误提示** — 不再嵌套在卡片内,直接放在 FASettingsExpanderItem 内
+
+---
+
+## Verification Steps
+
+1. `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过
+2. 运行应用,导航到设置 > 更新页面,验证:
+ - 页面布局与 GeneralSettingsPage 风格一致
+ - 无圆角矩形卡片包裹
+ - 检查更新按钮可用
+ - 进度区域在空闲时隐藏
+ - 版本信息以行项目形式展示
+ - 偏好设置使用 ComboBox
+ - 操作按钮仅显示当前可用的
+3. 点击「检查更新」,验证状态变化和进度展示
+4. 验证偏好设置的 ComboBox 选择能正确保存和加载
diff --git a/.trae/documents/weather-widget-material-redesign.md b/.trae/documents/weather-widget-material-redesign.md
new file mode 100644
index 0000000..cad8cfb
--- /dev/null
+++ b/.trae/documents/weather-widget-material-redesign.md
@@ -0,0 +1,559 @@
+# 天气组件 Material Design 重设计计划
+
+> **目标:** 全面重构阑山桌面天气组件的视觉设计,遵循 Material Design 3 规范,参考 Google Weather、几何天气、Breez 天气和柠檬天气的设计语言。
+
+---
+
+## 当前状态分析
+
+### 现有组件
+1. **WeatherWidget** - 基础天气(温度+天气状况+位置)
+2. **ExtendedWeatherWidget** - 扩展天气(含指标、逐小时、逐日预报)
+3. **HourlyWeatherWidget** - 逐小时天气
+4. **MultiDayWeatherWidget** - 多日天气
+5. **WeatherClockWidget** - 天气时钟
+
+### 现有问题
+- 排版层次不清晰,文字大小对比不够
+- 布局过于紧凑,缺乏呼吸感
+- 内部卡片使用简单纯色背景,缺乏 Material 风格
+- 背景场景和前景内容缺乏深度分离
+- 圆角和间距不统一
+
+### 现有视觉系统
+- 4套调色板:Google(默认)、Geometric、Breezy、LemonFlutter
+- 动态背景场景:MaterialWeatherSceneControl 绘制渐变+装饰
+- 图标系统:WeatherIconView + WeatherIconAssetResolver
+
+---
+
+## 设计方向
+
+### 核心原则
+1. **Material Design 3** - 使用 M3 的排版、颜色、间距和形状规范
+2. **信息层级清晰** - 大字体温度、次要信息弱化
+3. **呼吸感** - 合理的间距和留白
+4. **深度感** - 前景卡片与背景场景分离
+5. **圆角一致性** - 遵循 DesignCornerRadius 规范
+
+### 参考风格
+- **Google Weather** - 大字体温度、清晰层级、圆角卡片、柔和渐变
+- **几何天气** - 几何装饰、现代感
+- **Breez** - 清新留白、柔和色彩
+- **柠檬天气** - 活泼明亮
+
+---
+
+## 具体改动计划
+
+### Task 1: 优化 MaterialWeatherPalette 和调色板系统
+
+**文件:** `LanMountainDesktop/Views/Components/MaterialWeatherVisualTheme.cs`
+
+**改动:**
+- 调整所有调色板的对比度,确保文字可读性
+- 优化背景渐变色彩,更加柔和自然
+- 统一文字主色和次色的对比度比例
+- 为每个风格增加 `SurfaceColor` 和 `SurfaceVariantColor` 用于卡片背景
+
+**当前调色板字段:**
+```csharp
+public sealed record MaterialWeatherPalette(
+ Color BackgroundTop,
+ Color BackgroundBottom,
+ Color PrimaryShape,
+ Color SecondaryShape,
+ Color AccentShape,
+ Color TextPrimary,
+ Color TextSecondary,
+ Color SurfaceTint,
+ Color OverlayTint);
+```
+
+**新增字段:**
+```csharp
+ Color SurfaceColor, // 卡片表面色(低透明度白色/黑色)
+ Color SurfaceVariantColor, // 变体表面色
+ Color OutlineColor // 分割线/边框色
+```
+
+---
+
+### Task 2: 重构 WeatherWidget(基础天气组件)
+
+**文件:**
+- `LanMountainDesktop/Views/Components/WeatherWidget.axaml`
+- `LanMountainDesktop/Views/Components/WeatherWidget.axaml.cs`
+
+**设计目标:**
+- 大字体温度显示(类似 Google Weather)
+- 天气状况文字清晰可读
+- 位置和温度范围弱化显示
+- 图标与文字对齐优化
+
+**XAML 改动:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**CS 改动:**
+- 调整响应式布局的字体缩放比例
+- 更新颜色绑定使用新的调色板字段
+
+---
+
+### Task 3: 重构 ExtendedWeatherWidget(扩展天气组件)
+
+**文件:**
+- `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml`
+- `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs`
+
+**设计目标:**
+- 顶部区域:位置+温度+图标横向排列
+- 指标区域:使用 Material 3 风格的标签卡片
+- 逐小时预报:水平滚动卡片,时间+图标+温度
+- 逐日预报:列表式布局,日期+图标+高低温
+
+**XAML 改动:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**CS 改动:**
+- `CreateMetric` 方法:使用圆角卡片,Material 3 风格标签
+- `BuildHourlyItems` 方法:改进卡片样式,统一圆角
+- `BuildDailyItems` 方法:改进卡片样式,统一圆角
+
+---
+
+### Task 4: 重构 HourlyWeatherWidget(逐小时天气组件)
+
+**文件:**
+- `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml`
+- `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs`
+
+**设计目标:**
+- 顶部简洁信息栏
+- 逐小时预报使用 Material 卡片风格
+- 时间、图标、温度垂直排列
+
+**XAML 改动:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+### Task 5: 重构 MultiDayWeatherWidget(多日天气组件)
+
+**文件:**
+- `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml`
+- `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs`
+
+**设计目标:**
+- 左侧:当前天气信息(图标+温度+状况+位置)
+- 右侧:多日预报列表,使用行式布局
+
+**XAML 改动:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+### Task 6: 重构 WeatherClockWidget(天气时钟组件)
+
+**文件:**
+- `LanMountainDesktop/Views/Components/WeatherClockWidget.axaml`
+- `LanMountainDesktop/Views/Components/WeatherClockWidget.axaml.cs`
+
+**设计目标:**
+- 左侧:大字体时间+日期
+- 右侧:天气图标+温度+状况
+- 信息层级清晰
+
+**XAML 改动:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+### Task 7: 更新 ExtendedWeatherWidget 的代码后置文件
+
+**文件:** `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs`
+
+**改动:**
+- `CreateMetric` 方法改进:
+ - 使用 `DesignCornerRadiusSm` 圆角
+ - 使用新的 `SurfaceColor` 作为卡片背景
+ - 优化字体大小和间距
+
+- `BuildHourlyItems` 方法改进:
+ - 使用 `DesignCornerRadiusSm` 圆角
+ - 使用 `SurfaceColor` 作为卡片背景
+ - 时间、图标、温度垂直排列,居中对齐
+
+- `BuildDailyItems` 方法改进:
+ - 使用 `DesignCornerRadiusSm` 圆角
+ - 使用 `SurfaceColor` 作为卡片背景
+ - 日期、图标、高低温垂直排列
+
+---
+
+### Task 8: 更新 HourlyWeatherWidget 的代码后置文件
+
+**文件:** `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs`
+
+**改动:**
+- `CreateChip` 方法改进:
+ - 使用 `DesignCornerRadiusSm` 圆角
+ - 使用 `SurfaceColor` 作为卡片背景
+ - 优化垂直排列的间距
+
+---
+
+### Task 9: 更新 MultiDayWeatherWidget 的代码后置文件
+
+**文件:** `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs`
+
+**改动:**
+- `CreateRow` 方法改进:
+ - 添加底部分割线(除最后一行)
+ - 优化列间距和对齐
+ - 高低温使用不同透明度区分
+
+---
+
+### Task 10: 更新 MaterialWeatherVisualTheme 调色板
+
+**文件:** `LanMountainDesktop/Views/Components/MaterialWeatherVisualTheme.cs`
+
+**改动:**
+- 为 `MaterialWeatherPalette` 添加新字段:
+ - `SurfaceColor` - 用于卡片表面
+ - `SurfaceVariantColor` - 用于变体表面
+ - `OutlineColor` - 用于分割线
+
+- 更新所有调色板生成方法:
+ - `ResolveGooglePalette`
+ - `ResolveGeometricPalette`
+ - `ResolveBreezyPalette`
+ - `ResolveLemonPalette`
+
+- 每个调色板需要为白天/夜晚模式提供合适的 SurfaceColor:
+ - 白天:低透明度白色(如 `#14FFFFFF`)
+ - 夜晚:低透明度黑色(如 `#1A000000`)
+
+---
+
+### Task 11: 构建和测试
+
+**命令:**
+```bash
+dotnet build LanMountainDesktop.slnx -c Debug
+dotnet test LanMountainDesktop.slnx -c Debug
+```
+
+**验证清单:**
+- [ ] 所有天气组件正常编译
+- [ ] 运行时无异常
+- [ ] 4套视觉风格正常切换
+- [ ] 响应式布局正常工作
+- [ ] 圆角资源正确应用
+
+---
+
+## 文件改动汇总
+
+| 文件 | 改动类型 | 说明 |
+|------|---------|------|
+| `MaterialWeatherVisualTheme.cs` | 修改 | 添加 SurfaceColor 等字段,更新所有调色板 |
+| `WeatherWidget.axaml` | 修改 | 重构布局,优化排版 |
+| `WeatherWidget.axaml.cs` | 修改 | 调整响应式布局和颜色绑定 |
+| `ExtendedWeatherWidget.axaml` | 修改 | 重构布局,添加卡片容器 |
+| `ExtendedWeatherWidget.axaml.cs` | 修改 | 改进卡片创建方法 |
+| `HourlyWeatherWidget.axaml` | 修改 | 重构布局,添加卡片容器 |
+| `HourlyWeatherWidget.axaml.cs` | 修改 | 改进卡片创建方法 |
+| `MultiDayWeatherWidget.axaml` | 修改 | 重构布局,添加卡片容器 |
+| `MultiDayWeatherWidget.axaml.cs` | 修改 | 改进行创建方法 |
+| `WeatherClockWidget.axaml` | 修改 | 重构布局,优化排版 |
+| `WeatherClockWidget.axaml.cs` | 修改 | 调整响应式布局 |
+
+---
+
+## 设计规范检查清单
+
+- [ ] 所有组件根容器使用 `DesignCornerRadiusComponent`
+- [ ] 内部卡片使用 `DesignCornerRadiusMd` 或 `DesignCornerRadiusSm`
+- [ ] 不使用硬编码圆角值
+- [ ] 文字对比度符合 VISUAL_SPEC 要求
+- [ ] 间距使用一致的倍数(4px 基线)
+- [ ] 字体层级:温度(64-72px) > 状况(16-18px) > 位置/范围(12-13px)
diff --git a/.trae/documents/weather-widget-visual-redesign.md b/.trae/documents/weather-widget-visual-redesign.md
new file mode 100644
index 0000000..3949b5e
--- /dev/null
+++ b/.trae/documents/weather-widget-visual-redesign.md
@@ -0,0 +1,342 @@
+# 天气组件视觉重构 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:** 彻底重构阑山桌面天气系列组件的背景视觉和文字排版,为每种图标风格(Google Weather / Geometric / Breezy / Lemon)提供独立的背景配色和视觉质感,参考各天气 App 的 Material Design 风格,实现几何质感+柔和渐变+层次分明的排版。
+
+**Architecture:** 保留现有数据层(WeatherWidgetBase、WeatherSnapshot、WeatherIconAssetResolver)和组件注册机制不变。核心改动:1) 将 `MaterialWeatherVisualTheme.ResolvePalette()` 扩展为按 styleId 分派不同配色方案;2) 重构 `MaterialWeatherSceneControl` 为按 styleId 渲染不同背景风格;3) 改进各天气 Widget 的文字排版层次。先创建 HTML Mock 验证视觉效果。
+
+**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览)
+
+---
+
+## 当前状态分析
+
+### 现有天气组件体系
+5 个天气组件,全部继承自 `WeatherWidgetBase`:
+
+| 组件 | 文件 | 功能 |
+|------|------|------|
+| WeatherWidget | `WeatherWidget.axaml(.cs)` | 基础天气:温度+状况+图标+位置 |
+| WeatherClockWidget | `WeatherClockWidget.axaml(.cs)` | 天气+时钟 |
+| ExtendedWeatherWidget | `ExtendedWeatherWidget.axaml(.cs)` | 扩展天气:含指标/小时/多日预报 |
+| HourlyWeatherWidget | `HourlyWeatherWidget.axaml(.cs)` | 逐小时天气 |
+| MultiDayWeatherWidget | `MultiDayWeatherWidget.axaml(.cs)` | 多日天气 |
+
+### 核心问题
+
+1. **背景与图标风格脱钩**: `MaterialWeatherVisualTheme.ResolvePalette()` 只返回一套配色,与 `WeatherVisualStyleId`(GoogleWeatherV4/Geometric/Breezy/LemonFlutter)完全无关。切换图标风格时背景不变。
+2. **背景视觉单调**: `MaterialWeatherSceneControl` 只有一种手绘几何风格(椭圆+云+雨滴),质感差,缺乏各 App 的特色。
+3. **文字排版粗糙**: 温度数字不够大,信息层次不分明,指标用纯文字堆叠,预报区域无卡片样式。
+4. **半透明遮罩硬编码**: 所有组件都覆盖 `` 等硬编码遮罩,不随风格变化。
+
+### 各天气 App 风格特征
+
+**Google Weather (v4)**:
+- 背景:大面积柔和蓝白渐变,晴天偏暖黄蓝,雨天偏深蓝灰
+- 装饰:极简,几乎无几何装饰,纯靠渐变色彩表现天气氛围
+- 排版:温度超大(72px+),天气状况中等,位置小字
+
+**Geometric Weather (几何天气)**:
+- 背景:深色系渐变(深蓝/深紫/深灰),搭配半透明几何圆形装饰
+- 装饰:大面积半透明圆形叠加,营造深度感
+- 排版:紧凑信息密度,指标用小标签
+
+**Breezy Weather (微风天气)**:
+- 背景:清新渐变(浅蓝/浅绿/浅紫),比 Geometric 更明亮
+- 装饰:柔和波浪线条 + 少量几何装饰,Material Design 风格
+- 排版:卡片式预报,圆角芯片
+
+**Lemon Weather (柠檬天气)**:
+- 背景:暖色系渐变(橙黄/粉紫/暖蓝),柠檬2偏扁平,柠檬3偏Material
+- 装饰:天气场景装饰(太阳光芒/云朵轮廓/雨丝),更有场景感
+- 排版:温度超大,天气图标突出
+
+---
+
+## 设计方案
+
+### 视觉论文 (Visual Thesis)
+每种图标风格拥有独特的背景渐变配色和几何装饰语言——Google 纯净渐变、Geometric 深色几何、Breezy 清新波浪、Lemon 暖色场景——配合超大温度数字和层次分明的排版,在桌面小组件空间内实现 Material Design 的几何质感。
+
+### 配色方案设计
+
+每种风格 × 每种天气条件 × 昼夜 = 独立配色。以下为关键配色定义:
+
+#### Google Weather 风格
+| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom |
+|------|----------------|----------------|
+| Clear | #4FC3F7 → #B3E5FC | #0D47A1 → #1A237E |
+| PartlyCloudy | #81D4FA → #E1F5FE | #1565C0 → #283593 |
+| Cloudy | #90A4AE → #CFD8DC | #37474F → #455A64 |
+| Rain | #78909C → #B0BEC5 | #263238 → #37474F |
+| Storm | #546E7A → #78909C | #1A1A2E → #263238 |
+| Snow | #E1F5FE → #FFFFFF | #1A237E → #283593 |
+| Fog/Haze | #B0BEC5 → #ECEFF1 | #455A64 → #546E7A |
+
+#### Geometric 风格
+| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom |
+|------|----------------|----------------|
+| Clear | #1A237E → #3949AB | #0A0E27 → #1A1A3E |
+| PartlyCloudy | #283593 → #5C6BC0 | #0D1033 → #1E1E4A |
+| Cloudy | #37474F → #607D8B | #1A1A2E → #2D2D44 |
+| Rain | #1A237E → #3F51B5 | #0A0E27 → #1A1A3E |
+| Storm | #1A1A2E → #3F51B5 | #050510 → #1A1A2E |
+| Snow | #E8EAF6 → #C5CAE9 | #1A237E → #283593 |
+| Fog/Haze | #455A64 → #78909C | #1A1A2E → #37474F |
+
+#### Breezy 风格
+| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom |
+|------|----------------|----------------|
+| Clear | #4DD0E1 → #80DEEA | #006064 → #00838F |
+| PartlyCloudy | #4FC3F7 → #B2EBF2 | #00695C → #00897B |
+| Cloudy | #80CBC4 → #B2DFDB | #37474F → #546E7A |
+| Rain | #4DB6AC → #80CBC4 | #004D40 → #00695C |
+| Storm | #26A69A → #4DB6AC | #1A1A2E → #004D40 |
+| Snow | #E0F7FA → #FFFFFF | #006064 → #00838F |
+| Fog/Haze | #80CBC4 → #E0F7FA | #37474F → #546E7A |
+
+#### Lemon 风格
+| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom |
+|------|----------------|----------------|
+| Clear | #FFB74D → #FFF176 | #1A237E → #311B92 |
+| PartlyCloudy | #FF8A65 → #FFCC80 | #283593 → #4A148C |
+| Cloudy | #BCAAA4 → #D7CCC8 | #37474F → #4E342E |
+| Rain | #90A4AE → #B0BEC5 | #1A1A2E → #311B92 |
+| Storm | #78909C → #90A4AE | #0D0D1A → #1A1A2E |
+| Snow | #FFF9C4 → #FFFFFF | #1A237E → #311B92 |
+| Fog/Haze | #D7CCC8 → #EFEBE9 | #4E342E → #5D4037 |
+
+### 排版改进方案
+
+1. **温度超大化**: 温度字号从 56-58px 提升到 64-72px(基础组件),形成视觉锚点
+2. **层次分明**: 温度 → 天气状况 → 位置/指标,字号递减,透明度递减
+3. **指标标签化**: 湿度/风速/AQI 用半透明圆角标签展示,而非纯文字
+4. **预报芯片化**: 小时/每日预报用圆角半透明芯片卡片
+5. **图标间距**: 天气图标与文字之间增加 8-12px 间距
+
+---
+
+## 文件变更清单
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `Views/Components/MaterialWeatherVisualTheme.cs` | 修改 | 扩展 ResolvePalette 支持 styleId 分派,新增4套风格配色 |
+| `Views/Components/MaterialWeatherSceneControl.cs` | 修改 | 按 styleId 渲染不同背景风格(纯渐变/深色几何/清新波浪/暖色场景) |
+| `Views/Components/WeatherWidgetBase.cs` | 修改 | 传递 styleId 到 SceneControl.Apply(),移除硬编码遮罩 |
+| `Views/Components/WeatherWidget.axaml` | 修改 | 改进排版层次,移除硬编码遮罩 |
+| `Views/Components/WeatherWidget.axaml.cs` | 修改 | 适配新排版 |
+| `Views/Components/WeatherClockWidget.axaml` | 修改 | 改进排版,移除硬编码遮罩 |
+| `Views/Components/WeatherClockWidget.axaml.cs` | 修改 | 适配新排版 |
+| `Views/Components/ExtendedWeatherWidget.axaml` | 修改 | 改进排版,指标标签化,预报芯片化 |
+| `Views/Components/ExtendedWeatherWidget.axaml.cs` | 修改 | 适配新排版+标签+芯片 |
+| `Views/Components/HourlyWeatherWidget.axaml` | 修改 | 改进排版,预报芯片化 |
+| `Views/Components/HourlyWeatherWidget.axaml.cs` | 修改 | 适配新排版+芯片 |
+| `Views/Components/MultiDayWeatherWidget.axaml` | 修改 | 改进排版 |
+| `Views/Components/MultiDayWeatherWidget.axaml.cs` | 修改 | 适配新排版 |
+| `mocks/weather-widget-mock.html` | 新建 | HTML Mock 预览(4种风格×2种天气×2种主题) |
+
+---
+
+## Task 分解
+
+### Task 1: 创建 HTML Mock 预览
+
+**Files:**
+- Create: `mocks/weather-widget-mock.html`
+
+- [ ] **Step 1: 创建 HTML Mock 文件**
+
+创建完整的 HTML Mock,包含:
+- 4 种风格(Google / Geometric / Breezy / Lemon)× 2 种天气(晴/雨)× 2 种主题(亮/暗)
+- 每种风格展示基础天气组件(温度+状况+图标+位置)
+- 改进后的排版:超大温度、层次分明、指标标签化
+- 亮色/暗色主题切换按钮
+
+- [ ] **Step 2: 在浏览器中打开 Mock 验证效果**
+
+Run: `start mocks/weather-widget-mock.html`
+
+---
+
+### Task 2: 扩展 MaterialWeatherVisualTheme 支持多风格配色
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/Components/MaterialWeatherVisualTheme.cs`
+
+- [ ] **Step 1: 修改 ResolvePalette 方法签名**
+
+将 `ResolvePalette(MaterialWeatherCondition condition, bool isNight)` 改为 `ResolvePalette(string? styleId, MaterialWeatherCondition condition, bool isNight)`,内部按 styleId 分派到不同配色方案。
+
+- [ ] **Step 2: 新增 Google Weather 配色表**
+
+为 GoogleWeatherV4 风格定义所有天气条件×昼夜的配色(参考上面配色方案设计章节)。
+
+- [ ] **Step 3: 新增 Geometric 配色表**
+
+为 Geometric 风格定义深色系配色。
+
+- [ ] **Step 4: 新增 Breezy 配色表**
+
+为 Breezy 风格定义清新渐变配色。
+
+- [ ] **Step 5: 新增 Lemon 配色表**
+
+为 LemonFlutter 风格定义暖色系配色。
+
+- [ ] **Step 6: 更新所有调用点**
+
+将所有 `ResolvePalette(condition, isNight)` 调用改为 `ResolvePalette(styleId, condition, isNight)`。
+
+---
+
+### Task 3: 重构 MaterialWeatherSceneControl 支持多风格背景
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs`
+
+- [ ] **Step 1: 扩展 Apply 方法签名**
+
+将 `Apply(MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive)` 改为 `Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive)`,存储 styleId。
+
+- [ ] **Step 2: 实现 Google Weather 风格渲染**
+
+纯渐变背景,无几何装饰。背景使用 palette 的 BackgroundTop→BackgroundBottom 渐变。仅保留天气特效(雨滴/雪花/雾线)。
+
+- [ ] **Step 3: 实现 Geometric 风格渲染**
+
+深色渐变 + 大面积半透明几何圆形叠加。在基础渐变上叠加 2-3 个大椭圆(使用 palette 的 PrimaryShape/SecondaryShape/AccentShape),营造深度感。保留天气特效。
+
+- [ ] **Step 4: 实现 Breezy 风格渲染**
+
+清新渐变 + 柔和波浪线条。在基础渐变上绘制 2-3 条正弦波浪线(使用 palette 的 SurfaceTint),营造微风感。保留天气特效。
+
+- [ ] **Step 5: 实现 Lemon 风格渲染**
+
+暖色渐变 + 天气场景装饰。晴天绘制太阳光芒(放射线),多云绘制云朵轮廓,雨天绘制雨丝。保留天气特效。
+
+- [ ] **Step 6: 更新所有调用点**
+
+将所有 `SceneControl.Apply(condition, palette, isLive)` 改为 `SceneControl.Apply(styleId, condition, palette, isLive)`。
+
+---
+
+### Task 4: 更新 WeatherWidgetBase 传递 styleId
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/Components/WeatherWidgetBase.cs`
+
+- [ ] **Step 1: 修改 ApplyCurrentScene 方法**
+
+在 `ApplyCurrentScene()` 中将 `CurrentVisualStyleId` 传递给 `SceneControl.Apply()`。
+
+- [ ] **Step 2: 修改 ApplySnapshot 中的 ResolvePalette 调用**
+
+将 `MaterialWeatherVisualTheme.ResolvePalette(CurrentCondition, isNight)` 改为 `MaterialWeatherVisualTheme.ResolvePalette(CurrentVisualStyleId, CurrentCondition, isNight)`。
+
+---
+
+### Task 5: 改进各天气 Widget 的 XAML 排版
+
+**Files:**
+- Modify: `WeatherWidget.axaml` — 移除硬编码遮罩 ``,改用 palette 驱动的半透明遮罩
+- Modify: `WeatherClockWidget.axaml` — 同上
+- Modify: `ExtendedWeatherWidget.axaml` — 同上 + 指标区域改用标签样式
+- Modify: `HourlyWeatherWidget.axaml` — 同上 + 预报区域改用芯片样式
+- Modify: `MultiDayWeatherWidget.axaml` — 同上
+
+- [ ] **Step 1: 移除所有硬编码遮罩**
+
+将 `` / `#42FFFFFF` / `#34FFFFFF` / `#38FFFFFF` / `#3CFFFFFF` 替换为 ``,在 code-behind 中根据 palette 设置遮罩颜色。
+
+- [ ] **Step 2: 改进 WeatherWidget 排版**
+
+增大温度字号(58→64),增加图标与文字间距,调整位置文字透明度。
+
+- [ ] **Step 3: 改进 WeatherClockWidget 排版**
+
+增大时钟字号,增加天气信息与时间间距。
+
+- [ ] **Step 4: 改进 ExtendedWeatherWidget 排版**
+
+指标用半透明圆角标签,小时/每日预报用圆角芯片卡片。
+
+- [ ] **Step 5: 改进 HourlyWeatherWidget 排版**
+
+预报区域用圆角芯片卡片样式。
+
+- [ ] **Step 6: 改进 MultiDayWeatherWidget 排版**
+
+每日预报行增加分隔线和更好的间距。
+
+---
+
+### Task 6: 更新各天气 Widget 的 code-behind
+
+**Files:**
+- Modify: 所有天气 Widget 的 `.axaml.cs` 文件
+
+- [ ] **Step 1: 更新 WeatherWidget.axaml.cs**
+
+- 设置 OverlayBorder 背景
+- 增大温度字号
+- 适配新排版参数
+
+- [ ] **Step 2: 更新 WeatherClockWidget.axaml.cs**
+
+- 设置 OverlayBorder 背景
+- 适配新排版
+
+- [ ] **Step 3: 更新 ExtendedWeatherWidget.axaml.cs**
+
+- 设置 OverlayBorder 背景
+- 指标标签化(CreateMetric 改为带圆角背景的标签)
+- 预报芯片化
+
+- [ ] **Step 4: 更新 HourlyWeatherWidget.axaml.cs**
+
+- 设置 OverlayBorder 背景
+- 预报芯片化(CreateChip 改为带圆角背景的芯片)
+
+- [ ] **Step 5: 更新 MultiDayWeatherWidget.axaml.cs**
+
+- 设置 OverlayBorder 背景
+- 适配新排版
+
+---
+
+### Task 7: 验证与测试
+
+- [ ] **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. **4 套独立风格**: 每种图标风格对应独立的背景配色和装饰风格,切换图标风格时背景也跟着变
+2. **配色表驱动**: 所有颜色定义在 `MaterialWeatherVisualTheme` 中,不硬编码到 SceneControl
+3. **保留天气特效**: 雨滴/雪花/雾线/闪电等天气特效在所有风格中保留,但颜色跟随 palette
+4. **遮罩动态化**: 半透明遮罩颜色从 palette 中派生,而非硬编码 `#30FFFFFF`
+5. **排版渐进改进**: 不做大规模 XAML 重构,而是在现有结构上优化字号/间距/透明度
+6. **数据层不变**: WeatherSnapshot、WeatherIconAssetResolver、WeatherWidgetBase 的数据逻辑不变
+7. **接口兼容**: IDesktopComponentWidget 等接口实现不变
+
+## 验证步骤
+
+1. HTML Mock 在浏览器中展示 4 种风格效果满意
+2. Avalonia 项目编译通过
+3. 运行项目,切换图标风格时背景配色和装饰风格跟着变化
+4. 亮色/暗色主题切换正常
+5. 5 个天气组件排版层次分明
+6. 指标标签化和预报芯片化正常显示
+7. 测试通过
diff --git a/.trae/specs/air-app-whiteboard/checklist.md b/.trae/specs/air-app-whiteboard/checklist.md
new file mode 100644
index 0000000..09e5399
--- /dev/null
+++ b/.trae/specs/air-app-whiteboard/checklist.md
@@ -0,0 +1,7 @@
+# Checklist
+
+- [x] Main app builds in Debug.
+- [x] AirAppHost builds in Debug.
+- [x] Tests project builds in Debug.
+- [x] `AirAppLauncherServiceTests` pass.
+- [ ] Manual UI verification on a running desktop session.
diff --git a/.trae/specs/air-app-whiteboard/spec.md b/.trae/specs/air-app-whiteboard/spec.md
new file mode 100644
index 0000000..4d3dd95
--- /dev/null
+++ b/.trae/specs/air-app-whiteboard/spec.md
@@ -0,0 +1,26 @@
+# Air APP Whiteboard
+
+## Goal
+
+Allow the built-in whiteboard desktop components to open a full-screen Air APP that runs in `LanMountainDesktop.AirAppHost` and reuses the same persisted whiteboard note as the source component instance.
+
+## Scope
+
+- Add a toolbar surface-mode button to `WhiteboardWidget`.
+- In component mode, the button opens the `whiteboard` Air APP through `IAirAppLauncherService`.
+- In Air APP mode, the same button saves the current note and closes the Air APP window.
+- `DesktopWhiteboard` and `DesktopBlackboardLandscape` share the same mechanism and keep using their component id plus placement id as the note identity.
+- `LanMountainDesktop.AirAppHost` may reference the host assembly to reuse built-in UI controls, but the host app must not reference AirAppHost as a normal assembly dependency.
+
+## Out of Scope
+
+- Third-party Air APP SDK declarations.
+- Whiteboard feature rewrites or alternate whiteboard persistence.
+- Taskbar minimization behavior; v1 closes the Air APP window when the user exits from the bottom toolbar.
+
+## Acceptance
+
+- Building the main app also builds and copies `LanMountainDesktop.AirAppHost` output.
+- Clicking the whiteboard toolbar full-screen button launches a separate AirAppHost process.
+- Repeated opens of the same whiteboard component instance activate the existing process instead of spawning duplicates.
+- Closing and reopening the Air APP keeps the same whiteboard contents.
diff --git a/.trae/specs/air-app-whiteboard/tasks.md b/.trae/specs/air-app-whiteboard/tasks.md
new file mode 100644
index 0000000..ac10974
--- /dev/null
+++ b/.trae/specs/air-app-whiteboard/tasks.md
@@ -0,0 +1,8 @@
+# Tasks
+
+- [x] Add `whiteboard` launch support to `AirAppLauncherService`.
+- [x] Add whiteboard single-instance keys based on component id and placement id.
+- [x] Add component/Air APP surface modes to `WhiteboardWidget`.
+- [x] Render `WhiteboardWidget` full screen from `LanMountainDesktop.AirAppHost`.
+- [x] Keep AirAppHost build/copy output available from the main app build.
+- [x] Add launcher argument and instance-key tests.
diff --git a/.trae/specs/air-app-window-chrome/checklist.md b/.trae/specs/air-app-window-chrome/checklist.md
new file mode 100644
index 0000000..c3f30dd
--- /dev/null
+++ b/.trae/specs/air-app-window-chrome/checklist.md
@@ -0,0 +1,8 @@
+# Checklist
+
+- [x] Descriptor supports Standard, Borderless, FullScreen, Tool, and BackgroundOnly modes.
+- [x] World Clock Air APP uses FluentAvalonia standard title-bar chrome.
+- [x] Whiteboard Air APP opens as a fullscreen titlebar-less window.
+- [x] Air APP windows do not use fused desktop bottom-most services.
+- [x] Air APP windows do not use `Topmost=true` promotion.
+- [ ] Manual verification for each chrome mode once non-built-in Air APP declarations are added.
diff --git a/.trae/specs/air-app-window-chrome/spec.md b/.trae/specs/air-app-window-chrome/spec.md
new file mode 100644
index 0000000..83de3d8
--- /dev/null
+++ b/.trae/specs/air-app-window-chrome/spec.md
@@ -0,0 +1,22 @@
+# Air APP Window Chrome
+
+## Goal
+
+Give Air APPs explicit window chrome modes so title bars, fullscreen windows, borderless windows, tool windows, and future background-only apps are configured by the Air APP host instead of ad hoc component code.
+
+## Behavior
+
+- Air APP host resolves an `AirAppWindowDescriptor` from launch options before creating content.
+- Supported chrome modes are `Standard`, `Borderless`, `FullScreen`, `Tool`, and `BackgroundOnly`.
+- `Standard` uses FluentAvalonia `FAAppWindow` title-bar chrome and normal app-window behavior.
+- `Borderless` removes title-bar chrome while keeping a normal app window surface.
+- `FullScreen` removes title-bar chrome and enters fullscreen.
+- `Tool` keeps FluentAvalonia title-bar chrome but disables resizing and hides the taskbar entry.
+- `BackgroundOnly` is reserved for a later background Air APP lifecycle and is not used by built-in v1 apps.
+- Built-in `world-clock` uses `Standard`; built-in `whiteboard` uses `FullScreen`.
+
+## Out of Scope
+
+- Third-party plugin Air APP declarations.
+- Replacing Launcher lifecycle IPC.
+- Moving title-bar rendering into desktop components.
diff --git a/.trae/specs/air-app-window-chrome/tasks.md b/.trae/specs/air-app-window-chrome/tasks.md
new file mode 100644
index 0000000..e10f857
--- /dev/null
+++ b/.trae/specs/air-app-window-chrome/tasks.md
@@ -0,0 +1,8 @@
+# Tasks
+
+- [x] Add `AirAppWindowChromeMode` and `AirAppWindowDescriptor`.
+- [x] Map built-in `world-clock` to `Standard` chrome.
+- [x] Map built-in `whiteboard` to `FullScreen` chrome.
+- [x] Apply descriptor settings from `AirAppWindow`.
+- [x] Add regression tests for supported modes and built-in mode mapping.
+- [x] Replace the hand-rolled Air APP title bar with FluentAvalonia `FAAppWindow` chrome.
diff --git a/.trae/specs/clock-air-app-mvp/checklist.md b/.trae/specs/clock-air-app-mvp/checklist.md
new file mode 100644
index 0000000..2ffd09d
--- /dev/null
+++ b/.trae/specs/clock-air-app-mvp/checklist.md
@@ -0,0 +1,13 @@
+# Checklist
+
+- [x] Clicking `DesktopClock` and `DesktopWorldClock` opens the same global Clock Air APP type.
+- [x] Repeated `world-clock` open requests use the global `world-clock:clock-suite:global` instance key.
+- [x] Whiteboard Air APP keeps its per-component instance key behavior.
+- [x] Clock Air APP opens as a normal application window, not a desktop-layer window.
+- [x] Clock Air APP settings are independent from desktop clock widget settings.
+- [x] Corrupt Clock Air APP settings fall back to defaults.
+- [x] World clock time labels support 12-hour, 24-hour, and follow-system formatting.
+- [x] Added localization keys are present in all four supported language files.
+- [x] Build and automated tests pass.
+- [ ] Manual visual verification in all four languages.
+- [ ] Manual verification that minimizing keeps stopwatch and timer running while closing stops them.
diff --git a/.trae/specs/clock-air-app-mvp/spec.md b/.trae/specs/clock-air-app-mvp/spec.md
new file mode 100644
index 0000000..c20634d
--- /dev/null
+++ b/.trae/specs/clock-air-app-mvp/spec.md
@@ -0,0 +1,42 @@
+# Clock Air APP MVP
+
+## Goal
+
+Upgrade the built-in `world-clock` Air APP into a focused clock suite while keeping desktop clock widgets as lightweight launch entry points.
+
+## Scope
+
+- Keep the existing Air APP id `world-clock` for Launcher lifecycle compatibility.
+- Use one global Clock Air APP instance for every clock widget entry point.
+- Provide four tabs: World Clock, Stopwatch, Timer, and Settings.
+- Store Clock Air APP settings independently from desktop widget settings at `AirApps/Clock/settings.json`.
+- Follow the host language setting and provide localized text for `zh-CN`, `en-US`, `ja-JP`, and `ko-KR`.
+
+## Behavior
+
+- `world-clock` opens as a standard resizable FluentAvalonia window.
+- The default window size is approximately `780x560`, with a minimum of `680x480`.
+- World Clock shows local time and a configurable city list.
+- Default city list is Beijing, London, Sydney, and New York.
+- Users can add, remove, and reorder city entries during the Air APP session; the list persists across restarts.
+- Stopwatch supports start, pause, resume, lap, and reset; laps are kept in the current window session, up to 50 entries.
+- Timer supports fixed presets, a custom minute duration, start, pause, resume, reset, and a completed state.
+- Closing the Clock Air APP stops stopwatch and timer activity.
+- Minimizing the window keeps stopwatch and timer activity running.
+- Timer completion can activate the Clock Air APP window when the setting is enabled.
+
+## Settings
+
+- Time format: follow system, 24-hour, or 12-hour.
+- Show seconds.
+- Startup tab: last used tab, World Clock, Stopwatch, or Timer.
+- Activate window when timer finishes.
+
+## Out of Scope
+
+- Desktop clock widget visual redesign.
+- Alarms.
+- Focus mode.
+- System notifications.
+- Running stopwatch or timer after the Air APP window is closed.
+- Third-party plugin Air APP declarations.
diff --git a/.trae/specs/clock-air-app-mvp/tasks.md b/.trae/specs/clock-air-app-mvp/tasks.md
new file mode 100644
index 0000000..716f4f7
--- /dev/null
+++ b/.trae/specs/clock-air-app-mvp/tasks.md
@@ -0,0 +1,15 @@
+# Tasks
+
+- [x] Add Clock Air APP settings snapshot and JSON store.
+- [x] Add shared Clock Air APP time formatting helpers.
+- [x] Add stopwatch and timer state models with focused tests.
+- [x] Replace the old world-clock view with `ClockAirAppView`.
+- [x] Configure `world-clock` as a standard resizable Air APP window.
+- [x] Make `world-clock` use a global single-instance key independent of source component id.
+- [x] Add world clock city add, remove, and reorder behavior.
+- [x] Add stopwatch tab with lap support.
+- [x] Add timer tab with presets and custom duration.
+- [x] Add independent Clock Air APP settings tab.
+- [x] Add `zh-CN`, `en-US`, `ja-JP`, and `ko-KR` localization keys.
+- [x] Ensure AirAppHost output includes localization JSON resources.
+- [x] Add regression tests for Launcher keying, descriptors, settings, formatting, stopwatch, timer, and localization coverage.
diff --git a/.trae/specs/data-settings-page/design.md b/.trae/specs/data-settings-page/design.md
new file mode 100644
index 0000000..3714ce3
--- /dev/null
+++ b/.trae/specs/data-settings-page/design.md
@@ -0,0 +1,104 @@
+# 数据设置页设计文档
+
+## 概述
+
+在设置窗口中新增「数据」设置页,用于可视化展示和管理阑山桌面产生的各类本地数据。采用 Fluent Design 风格的横向堆叠条形图展示存储分布。
+
+## 设计目标
+
+1. 让用户直观了解阑山桌面占用的存储空间
+2. 提供各类数据的占比可视化
+3. 支持按类别清理数据
+4. 显示相对于磁盘总容量的占比
+
+## 页面结构
+
+### 存储概览区域
+
+顶部一个卡片,包含:
+- **横向堆叠条形图** — 各类数据用不同颜色的分段表示
+- **总占用大小** — 阑山桌面数据总大小(如 "1.2 GB")
+- **磁盘占比** — 占总磁盘空间的百分比(如 "占 C 盘 0.5%")
+- **图例** — 各颜色对应的数据类型
+
+### 数据类型详情列表
+
+下方列表展示每类数据:
+- 图标 + 名称
+- 占用大小
+- 描述/路径提示
+- 「清理」按钮(如适用)
+
+### 操作按钮
+
+- 「刷新」— 重新扫描数据大小
+- 「一键清理」— 清理所有可清理的数据
+
+## 数据类型
+
+| 类型 | 颜色 | 可清理 | 路径 |
+|------|------|--------|------|
+| 日志文件 | 灰色 | 是 | `log/` |
+| 白板笔记 | 橙色 | 是(过期) | `Whiteboards/` |
+| 插件数据 | 蓝色 | 是 | `Extensions/Plugins/` |
+| 插件市场缓存 | 紫色 | 是 | `PluginMarket/` |
+| 壁纸文件 | 粉色 | 是 | `Wallpapers/` |
+| 设置文件 | 绿色 | 否 | `settings.json` |
+
+## 技术实现
+
+### 新增文件
+
+- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` — 页面视图
+- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` — 页面代码隐藏
+- `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` — 视图模型
+- `LanMountainDesktop/Services/DataStorageService.cs` — 数据扫描服务
+
+### 修改文件
+
+- `LanMountainDesktop/Views/SettingsWindow.axaml.cs` — 图标映射(MapIcon)添加 Database 图标
+
+### 设置页注册
+
+```csharp
+[SettingsPageInfo(
+ "data",
+ "Data",
+ SettingsPageCategory.General,
+ IconKey = "Database",
+ SortOrder = 5,
+ TitleLocalizationKey = "settings.data.title",
+ DescriptionLocalizationKey = "settings.data.description")]
+```
+
+## 视觉设计
+
+### 堆叠条形图
+
+- 高度:24-32dp
+- 圆角:使用 `DesignCornerRadiusSm`
+- 分段间距:2dp
+- 未占用空间:透明或浅色背景
+
+### 颜色方案
+
+使用 Material Design 颜色,与主题协调:
+- 日志:Gray / BlueGray
+- 白板:Orange / Amber
+- 插件:Blue / Indigo
+- 缓存:Purple / DeepPurple
+- 壁纸:Pink
+- 设置:Green / Teal
+
+## 交互行为
+
+1. 页面加载时自动扫描数据大小(异步)
+2. 显示加载指示器
+3. 清理操作需要确认对话框
+4. 清理完成后自动刷新数据
+
+## 安全考虑
+
+- 清理前确认用户意图
+- 设置文件不可清理(防止误删配置)
+- 清理操作记录日志
diff --git a/.trae/specs/data-settings-page/plan.md b/.trae/specs/data-settings-page/plan.md
new file mode 100644
index 0000000..56b616e
--- /dev/null
+++ b/.trae/specs/data-settings-page/plan.md
@@ -0,0 +1,777 @@
+# 数据设置页实现计划
+
+> **Goal:** 在设置窗口中新增「数据」设置页,可视化展示阑山桌面各类本地数据的存储占用,支持数据清理。
+
+> **Architecture:** 采用 MVVM 模式,新增 DataStorageService 负责异步扫描各类数据大小,DataSettingsPage 使用 Fluent Design 横向堆叠条形图展示存储分布。
+
+> **Tech Stack:** Avalonia UI, FluentAvaloniaUI, CommunityToolkit.Mvvm, C# 13
+
+---
+
+## 文件结构
+
+| 文件 | 职责 |
+|------|------|
+| `LanMountainDesktop/Services/DataStorageService.cs` | 扫描各类数据目录大小,计算磁盘总容量 |
+| `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` | 数据设置页视图模型,绑定存储数据和清理命令 |
+| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` | 数据设置页 XAML 视图(堆叠条形图 + 列表) |
+| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` | 页面代码隐藏,注册设置页属性 |
+| `LanMountainDesktop/Views/SettingsWindow.axaml.cs` | 修改图标映射,添加 Database 图标 |
+
+---
+
+## Task 1: 创建 DataStorageService
+
+**Files:**
+- Create: `LanMountainDesktop/Services/DataStorageService.cs`
+
+**职责:** 扫描阑山桌面各类数据的存储占用,计算磁盘总容量。
+
+- [ ] **Step 1: 创建 DataStorageService**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LanMountainDesktop.Services;
+
+public sealed record StorageCategoryInfo(
+ string Id,
+ string Name,
+ string Description,
+ string DirectoryPath,
+ bool IsCleanable,
+ string ColorHex);
+
+public sealed record StorageScanResult(
+ StorageCategoryInfo Category,
+ long SizeBytes,
+ double PercentageOfTotal);
+
+public sealed class DataStorageService
+{
+ private static readonly IReadOnlyList Categories = new List
+ {
+ new("logs", "日志文件", "应用运行日志", "", true, "#9E9E9E"),
+ new("whiteboards", "白板笔记", "桌面白板笔记数据", "", true, "#FF9800"),
+ new("plugins", "插件数据", "已安装插件文件", "", true, "#2196F3"),
+ new("market", "插件市场缓存", "插件市场元数据缓存", "", true, "#9C27B0"),
+ new("wallpapers", "壁纸文件", "下载的壁纸资源", "", true, "#E91E63"),
+ new("settings", "设置文件", "应用配置数据", "", false, "#4CAF50")
+ };
+
+ public IReadOnlyList GetCategories() => Categories;
+
+ public async Task> ScanAsync(CancellationToken cancellationToken = default)
+ {
+ var results = new List();
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ var logDirectory = AppLogger.LogDirectory;
+
+ long totalSize = 0;
+ var categorySizes = new Dictionary();
+
+ foreach (var category in Categories)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string path = category.Id switch
+ {
+ "logs" => logDirectory,
+ "settings" => dataRoot,
+ _ => Path.Combine(dataRoot, category.DirectoryPath)
+ };
+
+ long size = 0;
+ if (category.Id == "settings")
+ {
+ size = await GetSettingsSizeAsync(dataRoot, cancellationToken);
+ }
+ else if (Directory.Exists(path))
+ {
+ size = await GetDirectorySizeAsync(path, cancellationToken);
+ }
+
+ categorySizes[category.Id] = size;
+ totalSize += size;
+ }
+
+ foreach (var category in Categories)
+ {
+ var size = categorySizes.GetValueOrDefault(category.Id, 0);
+ var percentage = totalSize > 0 ? (double)size / totalSize * 100 : 0;
+ results.Add(new StorageScanResult(category, size, percentage));
+ }
+
+ return results;
+ }
+
+ public async Task GetTotalDiskSpaceAsync(CancellationToken cancellationToken = default)
+ {
+ return await Task.Run(() =>
+ {
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ var driveInfo = new DriveInfo(Path.GetPathRoot(dataRoot) ?? dataRoot);
+ return driveInfo.TotalSize;
+ }, cancellationToken);
+ }
+
+ public async Task GetAvailableDiskSpaceAsync(CancellationToken cancellationToken = default)
+ {
+ return await Task.Run(() =>
+ {
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ var driveInfo = new DriveInfo(Path.GetPathRoot(dataRoot) ?? dataRoot);
+ return driveInfo.AvailableFreeSpace;
+ }, cancellationToken);
+ }
+
+ public async Task CleanCategoryAsync(string categoryId, CancellationToken cancellationToken = default)
+ {
+ var category = Categories.FirstOrDefault(c =>
+ string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase));
+
+ if (category is null || !category.IsCleanable)
+ {
+ return false;
+ }
+
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ string path = categoryId switch
+ {
+ "logs" => AppLogger.LogDirectory,
+ _ => Path.Combine(dataRoot, category.DirectoryPath)
+ };
+
+ if (!Directory.Exists(path))
+ {
+ return false;
+ }
+
+ return await Task.Run(() =>
+ {
+ try
+ {
+ if (categoryId == "logs")
+ {
+ foreach (var file in Directory.GetFiles(path, "*.log"))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ TryDeleteFile(file);
+ }
+ }
+ else
+ {
+ foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ TryDeleteFile(file);
+ }
+
+ foreach (var dir in Directory.GetDirectories(path, "*", SearchOption.AllDirectories)
+ .OrderByDescending(d => d.Length))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ TryDeleteDirectory(dir);
+ }
+ }
+
+ AppLogger.Info("DataStorage", $"Cleaned category '{categoryId}' at '{path}'.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("DataStorage", $"Failed to clean category '{categoryId}'.", ex);
+ return false;
+ }
+ }, cancellationToken);
+ }
+
+ private static async Task GetDirectorySizeAsync(string path, CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ long size = 0;
+ try
+ {
+ foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var info = new FileInfo(file);
+ if (info.Exists)
+ {
+ size += info.Length;
+ }
+ }
+ catch
+ {
+ // Ignore files we can't access
+ }
+ }
+ }
+ catch
+ {
+ // Ignore directories we can't access
+ }
+
+ return size;
+ }, cancellationToken);
+ }
+
+ private static async Task GetSettingsSizeAsync(string dataRoot, CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ long size = 0;
+ var settingFiles = new[] { "settings.json", "plugin-settings.json", "launcher-settings.json" };
+ foreach (var file in settingFiles)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var path = Path.Combine(dataRoot, file);
+ if (File.Exists(path))
+ {
+ try
+ {
+ size += new FileInfo(path).Length;
+ }
+ catch
+ {
+ // Ignore
+ }
+ }
+ }
+
+ return size;
+ }, cancellationToken);
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ File.SetAttributes(path, FileAttributes.Normal);
+ File.Delete(path);
+ }
+ catch
+ {
+ // Ignore deletion failures
+ }
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ Directory.Delete(path, false);
+ }
+ catch
+ {
+ // Ignore deletion failures
+ }
+ }
+
+ public static string FormatBytes(long bytes)
+ {
+ const long KB = 1024;
+ const long MB = KB * 1024;
+ const long GB = MB * 1024;
+ const long TB = GB * 1024;
+
+ return bytes switch
+ {
+ >= TB => $"{bytes / (double)TB:F2} TB",
+ >= GB => $"{bytes / (double)GB:F2} GB",
+ >= MB => $"{bytes / (double)MB:F2} MB",
+ >= KB => $"{bytes / (double)KB:F2} KB",
+ _ => $"{bytes} B"
+ };
+ }
+}
+```
+
+---
+
+## Task 2: 创建 DataSettingsPageViewModel
+
+**Files:**
+- Create: `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs`
+
+- [ ] **Step 1: 创建 ViewModel**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LanMountainDesktop.Services;
+
+namespace LanMountainDesktop.ViewModels;
+
+public sealed partial class DataStorageItemViewModel : ObservableObject
+{
+ public string Id { get; }
+ public string Name { get; }
+ public string Description { get; }
+ public string ColorHex { get; }
+ public bool IsCleanable { get; }
+
+ [ObservableProperty]
+ private string _sizeText = "--";
+
+ [ObservableProperty]
+ private double _percentage;
+
+ [ObservableProperty]
+ private bool _isCleaning;
+
+ public DataStorageItemViewModel(StorageCategoryInfo category)
+ {
+ Id = category.Id;
+ Name = category.Name;
+ Description = category.Description;
+ ColorHex = category.ColorHex;
+ IsCleanable = category.IsCleanable;
+ }
+
+ public void UpdateSize(long sizeBytes, double percentage)
+ {
+ SizeText = DataStorageService.FormatBytes(sizeBytes);
+ Percentage = percentage;
+ }
+}
+
+public sealed partial class DataSettingsPageViewModel : ViewModelBase
+{
+ private readonly DataStorageService _storageService = new();
+ private CancellationTokenSource? _scanCts;
+
+ [ObservableProperty]
+ private string _pageTitle = "数据与存储";
+
+ [ObservableProperty]
+ private string _totalSizeText = "--";
+
+ [ObservableProperty]
+ private string _diskUsageText = "--";
+
+ [ObservableProperty]
+ private double _diskUsagePercentage;
+
+ [ObservableProperty]
+ private bool _isScanning;
+
+ [ObservableProperty]
+ private bool _hasData;
+
+ public ObservableCollection Items { get; } = new();
+
+ public DataSettingsPageViewModel()
+ {
+ var categories = _storageService.GetCategories();
+ foreach (var category in categories)
+ {
+ Items.Add(new DataStorageItemViewModel(category));
+ }
+
+ _ = ScanAsync();
+ }
+
+ [RelayCommand]
+ private async Task ScanAsync()
+ {
+ _scanCts?.Cancel();
+ _scanCts = new CancellationTokenSource();
+ var token = _scanCts.Token;
+
+ IsScanning = true;
+ try
+ {
+ var results = await _storageService.ScanAsync(token);
+ var totalSize = results.Sum(r => r.SizeBytes);
+ var totalDisk = await _storageService.GetTotalDiskSpaceAsync(token);
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ TotalSizeText = DataStorageService.FormatBytes(totalSize);
+ DiskUsagePercentage = totalDisk > 0 ? (double)totalSize / totalDisk * 100 : 0;
+ DiskUsageText = $"占总磁盘 {DiskUsagePercentage:F1}%";
+ HasData = totalSize > 0;
+
+ foreach (var result in results)
+ {
+ var item = Items.FirstOrDefault(i =>
+ string.Equals(i.Id, result.Category.Id, StringComparison.OrdinalIgnoreCase));
+ item?.UpdateSize(result.SizeBytes, result.PercentageOfTotal);
+ }
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ // Ignore cancellation
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("DataSettings", "Failed to scan storage.", ex);
+ }
+ finally
+ {
+ IsScanning = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task CleanAsync(string categoryId)
+ {
+ var item = Items.FirstOrDefault(i =>
+ string.Equals(i.Id, categoryId, StringComparison.OrdinalIgnoreCase));
+ if (item is null || !item.IsCleanable)
+ {
+ return;
+ }
+
+ item.IsCleaning = true;
+ try
+ {
+ await _storageService.CleanCategoryAsync(categoryId);
+ await ScanAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("DataSettings", $"Failed to clean category '{categoryId}'.", ex);
+ }
+ finally
+ {
+ item.IsCleaning = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task CleanAllAsync()
+ {
+ foreach (var item in Items.Where(i => i.IsCleanable))
+ {
+ item.IsCleaning = true;
+ }
+
+ try
+ {
+ foreach (var item in Items.Where(i => i.IsCleanable))
+ {
+ await _storageService.CleanCategoryAsync(item.Id);
+ }
+
+ await ScanAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("DataSettings", "Failed to clean all categories.", ex);
+ }
+ finally
+ {
+ foreach (var item in Items)
+ {
+ item.IsCleaning = false;
+ }
+ }
+ }
+}
+```
+
+---
+
+## Task 3: 创建 DataSettingsPage.axaml
+
+**Files:**
+- Create: `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml`
+
+- [ ] **Step 1: 创建 XAML 视图**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Task 4: 创建 DataSettingsPage.axaml.cs
+
+**Files:**
+- Create: `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs`
+
+- [ ] **Step 1: 创建代码隐藏**
+
+```csharp
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.ViewModels;
+
+namespace LanMountainDesktop.Views.SettingsPages;
+
+[SettingsPageInfo(
+ "data",
+ "Data",
+ SettingsPageCategory.General,
+ IconKey = "Database",
+ SortOrder = 5,
+ TitleLocalizationKey = "settings.data.title",
+ DescriptionLocalizationKey = "settings.data.description")]
+public partial class DataSettingsPage : SettingsPageBase
+{
+ public DataSettingsPage()
+ : this(new DataSettingsPageViewModel())
+ {
+ }
+
+ public DataSettingsPage(DataSettingsPageViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ DataContext = ViewModel;
+ InitializeComponent();
+ }
+
+ public DataSettingsPageViewModel ViewModel { get; }
+}
+```
+
+---
+
+## Task 5: 修改 SettingsWindow.axaml.cs 添加图标映射
+
+**Files:**
+- Modify: `LanMountainDesktop/Views/SettingsWindow.axaml.cs`
+
+- [ ] **Step 1: 在 MapIcon 方法中添加 Database 图标映射**
+
+在 `MapIcon` 方法的 switch 表达式中添加:
+
+```csharp
+"Database" => Symbol.Database,
+```
+
+---
+
+## Task 6: 添加颜色转换器(如需要)
+
+**Files:**
+- Modify: `LanMountainDesktop/Theme/` 或 `LanMountainDesktop/Controls/` 中的资源字典
+
+如果项目中没有 HexToBrushConverter,需要创建一个简单的值转换器:
+
+```csharp
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace LanMountainDesktop.Converters;
+
+public class HexToBrushConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is string hex && !string.IsNullOrWhiteSpace(hex))
+ {
+ try
+ {
+ return new SolidColorBrush(Color.Parse(hex));
+ }
+ catch
+ {
+ // Ignore parse errors
+ }
+ }
+
+ return new SolidColorBrush(Colors.Gray);
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+}
+```
+
+---
+
+## 测试验证
+
+1. 构建项目:`dotnet build LanMountainDesktop.slnx -c Debug`
+2. 运行应用:`dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`
+3. 打开设置窗口,确认「数据」选项卡出现在左侧导航中
+4. 点击「数据」选项卡,确认:
+ - 堆叠条形图显示各类数据占比
+ - 总大小和磁盘占比显示正确
+ - 数据详情列表显示每类数据大小
+ - 刷新按钮可以重新扫描
+ - 清理按钮可以清理对应数据
diff --git a/.trae/specs/dock-back-to-windows-button-display/checklist.md b/.trae/specs/dock-back-to-windows-button-display/checklist.md
new file mode 100644
index 0000000..ebf0896
--- /dev/null
+++ b/.trae/specs/dock-back-to-windows-button-display/checklist.md
@@ -0,0 +1,13 @@
+# Checklist
+
+- [ ] `AppSettingsSnapshot.BackToWindowsButtonDisplayMode` exists and defaults to `IconAndText`.
+- [ ] `AppSettingsSnapshot` contains icon source, Fluent icon name, and text icon settings with safe defaults.
+- [ ] General > Basic Settings includes one folded back-to-platform button settings expander.
+- [ ] The expander includes the display-mode dropdown.
+- [ ] The expander includes nested icon source, Fluent icon popup picker, and text icon input controls.
+- [ ] The Dock button left icon slot renders either a Fluent icon or custom text.
+- [ ] `IconAndText`, `IconOnly`, and `TextOnly` modes update the Dock button live.
+- [ ] Icon source, Fluent icon name, and text icon updates refresh the Dock button live.
+- [ ] The selected mode is preserved when MainWindow saves app settings.
+- [ ] Localization keys exist for zh-CN, en-US, ja-JP, and ko-KR.
+- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` succeeds.
diff --git a/.trae/specs/dock-back-to-windows-button-display/spec.md b/.trae/specs/dock-back-to-windows-button-display/spec.md
new file mode 100644
index 0000000..c0d108b
--- /dev/null
+++ b/.trae/specs/dock-back-to-windows-button-display/spec.md
@@ -0,0 +1,29 @@
+# Dock Back To Windows Button Display
+
+## Summary
+
+The Dock "Back to platform" action should expose a configurable left icon slot while keeping the localized platform text fixed.
+
+## Requirements
+
+- The default display mode is `IconAndText` so existing users keep a familiar Dock layout after upgrade.
+- The localized platform text remains controlled by the app and is not user-editable.
+- General > Basic Settings exposes one Fluent Avalonia `FASettingsExpander` for the back-to-platform button, with icon-related controls folded into nested `FASettingsExpanderItem` rows.
+- The main row exposes a dropdown with `IconAndText`, `IconOnly`, and `TextOnly` options.
+- A nested icon source row selects Fluent icon or text icon.
+- Fluent icon mode uses a popup picker-style flyout with search and a grid of the full FluentIcons `Icon` enum.
+- Text icon mode lets the user enter short text for the left icon slot.
+- Changing the dropdown persists to `AppSettingsSnapshot.BackToWindowsButtonDisplayMode` and updates the Dock button without restarting.
+- Changing the icon source, Fluent icon, or text icon persists to app settings and updates the Dock button without restarting.
+- `IconOnly` keeps the existing tooltip text so the button remains understandable.
+- `PinnedTaskbarActions` continues to control whether the action is visible; it does not replace the display mode setting.
+
+## Acceptance Scenarios
+
+- With default settings, the Dock button shows a small circle icon and the localized platform text.
+- Selecting icon only hides the platform text and keeps the configured left icon visible.
+- Selecting text only hides the left icon slot and keeps the localized platform text visible.
+- Choosing a Fluent icon changes the left icon slot.
+- Entering a short text icon changes the left icon slot.
+- Restarting the app restores the selected display mode.
+- Clicking the button still runs the existing minimize/back-to-platform behavior.
diff --git a/.trae/specs/fused-desktop-category-icon-unification/checklist.md b/.trae/specs/fused-desktop-category-icon-unification/checklist.md
new file mode 100644
index 0000000..de56722
--- /dev/null
+++ b/.trae/specs/fused-desktop-category-icon-unification/checklist.md
@@ -0,0 +1,14 @@
+- [x] ComponentCategoryIconResolver 基于 IconKey 正确解析分类图标
+- [x] IconKey 为 "Clock" 时解析为 Icon.Clock
+- [x] IconKey 为 "WeatherSunny" 时解析为 Icon.WeatherSunny
+- [x] IconKey 为 "News" 时解析为 Icon.News
+- [x] IconKey 为 "Edit" 时解析为 Icon.Edit
+- [x] IconKey 为无效值时回退到 Icon.Apps
+- [x] 分类 ID 为 "all" 时返回 Icon.Apps
+- [x] ComponentLibraryCategoryViewModel.Icon 类型为 FluentIcons.Common.Icon
+- [x] FusedDesktopComponentLibraryControl.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法
+- [x] ComponentLibraryWindow.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法
+- [x] MainWindow.ComponentSystem.cs 不再包含硬编码 ResolveComponentLibraryCategoryIcon 方法
+- [x] 三处组件库入口对同一分类显示相同图标
+- [x] dotnet build 无编译错误
+- [x] dotnet test 全部通过
diff --git a/.trae/specs/fused-desktop-category-icon-unification/spec.md b/.trae/specs/fused-desktop-category-icon-unification/spec.md
new file mode 100644
index 0000000..7cca3d8
--- /dev/null
+++ b/.trae/specs/fused-desktop-category-icon-unification/spec.md
@@ -0,0 +1,73 @@
+# 融合桌面组件库分类图标统一规格
+
+## Why
+
+融合桌面组件库窗口(FusedDesktopComponentLibraryControl)的分类图标使用了手动硬编码的 `ResolveCategoryIcon` 方法映射分类 ID 到 `Symbol` 枚举,与阑山桌面主窗口(MainWindow)中的映射存在不一致(例如 `Info` 分类在主窗口映射到 `Symbol.Apps`,在融合桌面映射到 `Symbol.Info`)。同时,`DesktopComponentDefinition.IconKey` 字段已经存储了正确的 FluentIcon 枚举名称字符串,但未被利用。需要统一三处图标映射逻辑,确保所有组件库入口的分类图标一致且正确。
+
+## What Changes
+
+- **统一分类图标映射**:将三处分散的 `ResolveCategoryIcon`/`ResolveComponentLibraryCategoryIcon` 方法合并为共享的统一映射
+- **使用 `IconKey` 驱动图标**:分类图标应基于该分类下组件的 `IconKey` 字段推导,而非硬编码的分类 ID 映射
+- **使用 `FluentIcons.Common.Icon` 枚举**:`fi:FluentIcon` 控件使用 `Icon` 枚举(非 `Symbol` 枚举),分类图标应使用 `Icon` 枚举以与 `fi:FluentIcon` 兼容
+- **修改 ViewModel**:`ComponentLibraryCategoryViewModel.Icon` 属性类型从 `Symbol` 改为 `Icon`
+
+## Impact
+
+- 受影响文件:
+ - `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(Icon 属性类型从 Symbol 改为 Icon)
+ - `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`(绑定路径不变,但 Icon 类型变化)
+ - `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`(移除硬编码映射,使用统一方法)
+ - `LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs`(移除硬编码映射,使用统一方法)
+ - `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`(移除硬编码映射,使用统一方法)
+ - 新增共享映射工具类(或在现有服务中添加)
+
+## ADDED Requirements
+
+### Requirement: 统一分类图标映射
+
+系统 SHALL 提供一个共享的分类图标映射方法,所有组件库入口(阑山桌面主窗口、融合桌面组件库、独立组件库窗口)均使用此方法。
+
+#### Scenario: 图标映射来源
+- **GIVEN** 一个组件分类 ID
+- **WHEN** 需要获取该分类的图标
+- **THEN** 系统应基于该分类下组件的 `IconKey` 字段推导分类图标
+- **AND** 推导规则为:取该分类下第一个组件的 `IconKey`,解析为 `FluentIcons.Common.Icon` 枚举值
+- **AND** 若 `IconKey` 无法解析为有效的 `Icon` 枚举值,则回退到 `Icon.Apps`
+
+#### Scenario: 特殊分类处理
+- **GIVEN** 分类 ID 为 "all"
+- **WHEN** 需要获取该分类的图标
+- **THEN** 系统应返回 `Icon.Apps`
+
+#### Scenario: 三处映射一致性
+- **GIVEN** 任意一个组件分类
+- **WHEN** 在阑山桌面主窗口、融合桌面组件库、独立组件库窗口中显示该分类
+- **THEN** 三处应显示完全相同的图标
+
+### Requirement: ViewModel 使用 Icon 枚举
+
+`ComponentLibraryCategoryViewModel.Icon` 属性 SHALL 使用 `FluentIcons.Common.Icon` 枚举类型(而非 `FluentIcons.Common.Symbol`),以与 `fi:FluentIcon` 控件的 `Icon` 属性兼容。
+
+#### Scenario: XAML 绑定兼容
+- **GIVEN** `ComponentLibraryCategoryViewModel.Icon` 属性类型为 `Icon`
+- **WHEN** 在 XAML 中通过 `{Binding Icon}` 绑定到 `fi:FluentIcon` 控件
+- **THEN** 图标应正确渲染,无需额外转换
+
+## MODIFIED Requirements
+
+### Requirement: 分类图标解析
+
+原实现使用硬编码的 `if/switch` 语句将分类 ID 映射到 `Symbol` 枚举,新实现改为:
+
+- 使用 `DesktopComponentDefinition.IconKey` 字段作为图标来源
+- 通过 `Enum.TryParse(iconKey, ignoreCase: true, out var icon)` 解析
+- 解析失败时回退到 `Icon.Apps`
+- 移除所有三处硬编码映射方法
+
+### Requirement: ComponentLibraryCategoryViewModel.Icon 类型
+
+原类型为 `Symbol`,修改为 `Icon`,与 `fi:FluentIcon` 控件的 `Icon` 依赖属性类型一致。
+
+## REMOVED Requirements
+
+无移除的需求。
diff --git a/.trae/specs/fused-desktop-category-icon-unification/tasks.md b/.trae/specs/fused-desktop-category-icon-unification/tasks.md
new file mode 100644
index 0000000..272f0b2
--- /dev/null
+++ b/.trae/specs/fused-desktop-category-icon-unification/tasks.md
@@ -0,0 +1,38 @@
+# Tasks
+
+- [x] Task 1: 创建共享分类图标映射工具
+ - [x] SubTask 1.1: 在 `LanMountainDesktop.ComponentSystem` 命名空间下创建 `ComponentCategoryIconResolver` 静态类
+ - [x] SubTask 1.2: 实现 `ResolveCategoryIcon(string categoryId, IEnumerable categoryComponents)` 方法,基于 IconKey 解析为 `FluentIcons.Common.Icon`
+ - [x] SubTask 1.3: 添加单元测试验证图标解析逻辑(TDD:先写失败测试,再实现)
+
+- [x] Task 2: 修改 ViewModel 的 Icon 属性类型
+ - [x] SubTask 2.1: 将 `ComponentLibraryCategoryViewModel.Icon` 属性类型从 `Symbol` 改为 `Icon`
+ - [x] SubTask 2.2: 更新构造函数参数类型
+
+- [x] Task 3: 更新 FusedDesktopComponentLibraryControl.axaml.cs
+ - [x] SubTask 3.1: 移除 `ResolveCategoryIcon` 硬编码方法
+ - [x] SubTask 3.2: 在 `LoadCategories` 中使用 `ComponentCategoryIconResolver.ResolveCategoryIcon`
+ - [x] SubTask 3.3: 更新 "all" 分类图标从 `Symbol.Apps` 改为 `Icon.Apps`
+
+- [x] Task 4: 更新 ComponentLibraryWindow.axaml.cs
+ - [x] SubTask 4.1: 移除 `ResolveCategoryIcon` 硬编码方法
+ - [x] SubTask 4.2: 使用 `ComponentCategoryIconResolver.ResolveCategoryIcon`
+
+- [x] Task 5: 更新 MainWindow.ComponentSystem.cs
+ - [x] SubTask 5.1: 移除 `ResolveComponentLibraryCategoryIcon` 硬编码方法
+ - [x] SubTask 5.2: 使用 `ComponentCategoryIconResolver.ResolveCategoryIcon`
+ - [x] SubTask 5.3: 更新 `ComponentLibraryCategory` 记录的 `Icon` 字段类型从 `Symbol` 改为 `Icon`
+ - [x] SubTask 5.4: 更新 `GetComponentLibraryCategories` 方法中的图标解析调用
+
+- [x] Task 6: 更新 XAML 绑定
+ - [x] SubTask 6.1: 验证 `FusedDesktopComponentLibraryControl.axaml` 中 `fi:FluentIcon Icon="{Binding Icon}"` 绑定在新类型下正常工作
+
+- [x] Task 7: 构建验证
+ - [x] SubTask 7.1: 运行 `dotnet build` 确保无编译错误
+ - [x] SubTask 7.2: 运行 `dotnet test` 确保所有测试通过
+
+# Task Dependencies
+- Task 2 依赖于 Task 1(共享映射工具)
+- Task 3、4、5 依赖于 Task 1 和 Task 2
+- Task 6 依赖于 Task 2(类型变更后验证绑定)
+- Task 7 依赖于所有前置任务
diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md b/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md
new file mode 100644
index 0000000..58242db
--- /dev/null
+++ b/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md
@@ -0,0 +1,10 @@
+# Checklist
+
+- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
+- [x] `LanMountainDesktop.Launcher` builds in Debug.
+- [x] `LanMountainDesktop` builds in Debug.
+- [x] `LanMountainDesktop.AirAppHost` builds in Debug.
+- [x] `LanMountainDesktop.Tests` builds in Debug.
+- [x] Air APP launcher and lifecycle unit tests pass.
+- [x] Direct-host fallback starts Launcher in `air-app-broker` mode instead of debug/normal launch mode.
+- [ ] Manual process-lifetime verification with the running desktop.
diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/spec.md b/.trae/specs/launcher-managed-air-app-lifecycle/spec.md
new file mode 100644
index 0000000..a7cf1bf
--- /dev/null
+++ b/.trae/specs/launcher-managed-air-app-lifecycle/spec.md
@@ -0,0 +1,22 @@
+# Launcher Managed Air APP Lifecycle
+
+## Goal
+
+Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
+
+## Behavior
+
+- Launcher exposes `IAirAppLifecycleService` on the dedicated `LanMountainDesktop.Launcher.AirApp.v1` pipe.
+- Desktop host calls Launcher IPC for `world-clock` and `whiteboard`; it does not directly start `LanMountainDesktop.AirAppHost`.
+- If the dedicated pipe is unavailable, the desktop host starts Launcher with the hidden `air-app-broker --requester-pid ` command and retries the Air APP request.
+- `air-app-broker` starts only the Air APP lifecycle IPC broker. It bypasses OOBE, Splash, debug preview windows, and normal desktop launch orchestration.
+- Launcher keeps one Air APP process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key.
+- AirAppHost receives Launcher pipe and instance key at startup, registers after the window opens, and unregisters on close.
+- Launcher remains alive while the main desktop process or any Air APP process is alive.
+- Broker mode remains alive while the requester desktop process or any Air APP process is alive; after both are gone, it exits.
+
+## Out of Scope
+
+- Third-party plugin-declared Air APP metadata.
+- Cross-machine IPC.
+- Persisting the Air APP instance table across OS reboot.
diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md b/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md
new file mode 100644
index 0000000..d9461b7
--- /dev/null
+++ b/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md
@@ -0,0 +1,11 @@
+# Tasks
+
+- [x] Add shared Air APP lifecycle IPC contracts.
+- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
+- [x] Make Launcher remain alive while desktop or Air APP processes exist.
+- [x] Route desktop Air APP launch requests through Launcher IPC.
+- [x] Add hidden `air-app-broker` Launcher command for direct-host development fallback.
+- [x] Make desktop fallback start `air-app-broker --requester-pid ` instead of normal `launch`.
+- [x] Add broker lifetime and command recognition tests.
+- [x] Add AirAppHost registration and unregister best-effort calls.
+- [x] Add lifecycle service and request-building tests.
diff --git a/.trae/specs/launcher-oobe-elevation-hardening/checklist.md b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md
index 1afaff9..b9003ac 100644
--- a/.trae/specs/launcher-oobe-elevation-hardening/checklist.md
+++ b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md
@@ -6,3 +6,4 @@
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
- [ ] Default plugin install does not request UAC.
- [ ] Logs include OOBE status, suppression reason, and launch source.
+- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
diff --git a/.trae/specs/launcher-shell-hardening/spec.md b/.trae/specs/launcher-shell-hardening/spec.md
index 2e724e6..d73c365 100644
--- a/.trae/specs/launcher-shell-hardening/spec.md
+++ b/.trae/specs/launcher-shell-hardening/spec.md
@@ -65,3 +65,19 @@
- 托盘失败时应用仍保持可恢复。
- Launcher 与应用设置页显示相同版本。
- 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口入场、通知位置与动画正常。
+
+### 5. Launcher IPC and error surface follow-up
+
+- The legacy `LanMountainDesktop_Launcher` named-pipe startup progress channel is retired. Public IPC notifications and host exit codes are the only startup state sources.
+- Normal Launcher launches must probe public IPC for an existing Host before starting a new Host process. Host no longer owns multi-instance policy, activation prompts, or the old single-instance pipe.
+- `SecondaryActivationSucceeded` is a success terminal state. `SecondaryActivationFailed` and `RestartLockNotAcquired` may surface as failures only after public IPC recovery has failed.
+- Launcher startup errors must use FluentAvalonia resources, Fluent icons, an InfoBar recovery hint, and copyable diagnostics instead of the old hard-coded dark panel.
+
+### 6. Multi-instance behavior setting
+
+- App settings include `MultiInstanceLaunchBehavior` with default `NotifyAndOpenDesktop`.
+- General settings exposes the behavior under Basic Settings with four choices: restart app, open desktop silently, prompt only, and notify plus open desktop.
+- Launcher reads the Host `settings.json` before a normal launch and applies the selected behavior when public IPC reports an existing Host.
+- `PromptOnly` shows a Fluent Launcher prompt and does not open the desktop automatically.
+- `NotifyAndOpenDesktop` activates the existing Host and shows the already-running notice from Launcher.
+- `RestartApp` requests restart through public IPC and must not create a second Host if the restart request fails.
diff --git a/.trae/specs/launcher-shell-hardening/tasks.md b/.trae/specs/launcher-shell-hardening/tasks.md
index 9644627..b139320 100644
--- a/.trae/specs/launcher-shell-hardening/tasks.md
+++ b/.trae/specs/launcher-shell-hardening/tasks.md
@@ -12,3 +12,10 @@
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
- [x] 补充规格与版本同步说明文档。
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
+
+- [x] Remove the legacy `LanMountainDesktop_Launcher` startup progress pipe; launcher progress now uses public IPC plus host exit-code classification only.
+- [x] Move normal multi-open probing into Launcher before host launch and remove Host-side single-instance prompt/listener code.
+- [x] Refresh the Launcher error window with Fluent resources, InfoBar, Fluent icons, command bar actions, and copyable diagnostic details.
+- [x] Add app-level `MultiInstanceLaunchBehavior` setting and expose it in General > Basic Settings.
+- [x] Make Launcher apply restart/open silently/prompt only/notify and open behavior before starting a new Host.
+- [x] Add a Fluent Launcher multi-instance prompt; Host public IPC stays limited to activation/status/restart/exit actions.
diff --git a/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md
index 3067a0f..eb31ed1 100644
--- a/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md
+++ b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md
@@ -4,14 +4,14 @@
- Tray menu `Exit App` must commit an irreversible host shutdown request.
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
-- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
+- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, and telemetry resources before the forced-exit deadline.
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
## Acceptance
-- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
+- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to perform multi-instance detection through public IPC.
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
- Repeated tray clicks during shutdown are ignored and logged.
- Repeated component-library clicks focus the existing window instead of opening duplicates.
diff --git a/.trae/specs/main-window-desktop-layer/design.md b/.trae/specs/main-window-desktop-layer/design.md
new file mode 100644
index 0000000..1e8f321
--- /dev/null
+++ b/.trae/specs/main-window-desktop-layer/design.md
@@ -0,0 +1,42 @@
+# Main Window Desktop Layer Design
+
+## Window Roles
+
+Lan Mountain Desktop now has three separate window-layer roles:
+
+- `MainDesktopWindow`: the normal desktop host window. With `EnableMainWindowDesktopLayer`, this window is moved to the desktop layer so it does not cover ordinary apps.
+- `FusedDesktopSurface`: fused desktop component windows such as `DesktopWidgetWindow` and `TransparentOverlayWindow`. These continue to use `IWindowBottomMostService` and their existing click-through region service.
+- `AirApp`: independent Air APP windows. These are ordinary app windows and do not use desktop-layer services or global `Topmost` promotion.
+
+## Service Boundary
+
+`IMainWindowDesktopLayerService` is dedicated to the main window only. It does not reuse fused desktop passthrough services because the main window must stay interactive.
+
+Windows behavior:
+
+- Save original parent, style, and extended style before enabling.
+- Try to attach the main window to the desktop icon host.
+- If that host is not found, use `HWND_BOTTOM`.
+- On disable, restore the saved parent and styles as best effort.
+
+Non-Windows behavior:
+
+- Keep a null implementation.
+- Log that the platform is unsupported.
+
+## Settings Flow
+
+The developer settings page owns confirmation UX for conflicts:
+
+- Fused desktop toggle and main-window desktop-layer toggle are one-way bound.
+- Toggle click handlers ask for confirmation before saving conflicting states.
+- The view model writes both keys together so runtime listeners receive a coherent change set.
+
+## Runtime Flow
+
+Main-window restore paths call `ActivateOrRefreshMainWindowLayer`.
+
+- If `EnableMainWindowDesktopLayer` is enabled, the app refreshes the desktop-layer attachment and hides the taskbar entry.
+- If disabled, the app restores ordinary activation behavior, including the existing temporary foreground promotion.
+
+Settings changes call both fused desktop and main-window desktop-layer runtime application paths so switching modes is immediate.
diff --git a/.trae/specs/main-window-desktop-layer/requirements.md b/.trae/specs/main-window-desktop-layer/requirements.md
new file mode 100644
index 0000000..d18a030
--- /dev/null
+++ b/.trae/specs/main-window-desktop-layer/requirements.md
@@ -0,0 +1,20 @@
+# Main Window Desktop Layer
+
+## Requirements
+
+- Add a developer option named `EnableMainWindowDesktopLayer`.
+- When enabled, the main Lan Mountain desktop window behaves like a desktop-surface window: ordinary application windows can stay above it.
+- The feature is implemented as desktop-layer or bottom placement, not as `Topmost`.
+- The option is mutually exclusive with `EnableFusedDesktop`.
+- Enabling main-window desktop layer while fused desktop is enabled must ask for confirmation, then disable fused desktop on confirm or roll back on cancel.
+- Enabling fused desktop while main-window desktop layer is enabled must ask for confirmation, then disable main-window desktop layer on confirm or roll back on cancel.
+- Air APP windows remain ordinary application windows and must not be attached to the desktop layer.
+- On Windows, the main window should attach to the desktop icon host when available and fall back to `HWND_BOTTOM` when unavailable.
+- On non-Windows platforms, the setting may exist but the layer service is a no-op and must not throw.
+
+## Acceptance
+
+- Opening another app above Lan Mountain Desktop keeps that app visible when main-window desktop layer is enabled.
+- Restoring the main window from tray keeps the desktop-layer behavior and does not perform a temporary `Topmost` promotion.
+- Turning the option off restores normal main-window behavior as far as possible.
+- Fused desktop component windows keep their existing bottom-most behavior and remain isolated from the main-window service.
diff --git a/.trae/specs/main-window-desktop-layer/tasks.md b/.trae/specs/main-window-desktop-layer/tasks.md
new file mode 100644
index 0000000..3380d54
--- /dev/null
+++ b/.trae/specs/main-window-desktop-layer/tasks.md
@@ -0,0 +1,10 @@
+# Main Window Desktop Layer Tasks
+
+- [x] Add `EnableMainWindowDesktopLayer` to app settings with a disabled default.
+- [x] Add developer settings UI and localization strings.
+- [x] Add confirmation flow for mutual exclusion with fused desktop.
+- [x] Add a dedicated main-window desktop-layer service.
+- [x] Wire main-window creation, restore, tray fallback, settings changes, and shutdown cleanup to the service.
+- [x] Keep Air APP windows outside this layer service.
+- [x] Add static regression tests for settings, restore paths, and service boundaries.
+- [ ] Perform manual Windows z-order validation with real apps.
diff --git a/.trae/specs/material-color-service/checklist.md b/.trae/specs/material-color-service/checklist.md
new file mode 100644
index 0000000..d56410f
--- /dev/null
+++ b/.trae/specs/material-color-service/checklist.md
@@ -0,0 +1,12 @@
+# Material Color Service Acceptance Checklist
+
+- [x] `dotnet build LanMountainDesktop.slnx -c Debug` succeeds.
+- [x] `dotnet test LanMountainDesktop.slnx -c Debug` succeeds.
+- [x] Material & Color page exposes color source, wallpaper source, system material, native event preference, polling interval, manual refresh, semantic color preview, and surface preview.
+- [x] Appearance page no longer owns duplicate visible color/material controls.
+- [x] Appearance page view model preserves Material & Color settings instead of rewriting them.
+- [x] Component corner-radius settings preserve Material & Color fields instead of resetting them through old positional constructors.
+- [x] Component editor receives colors from `MaterialColorSnapshot`.
+- [x] Plugin SDK snapshot includes read-only color/material fields without breaking the existing constructor shape.
+- [x] Wallpaper source selection supports auto, app, and system modes.
+- [x] Native wallpaper event monitoring can be disabled and polling remains available.
diff --git a/.trae/specs/material-color-service/spec.md b/.trae/specs/material-color-service/spec.md
new file mode 100644
index 0000000..91f3d7e
--- /dev/null
+++ b/.trae/specs/material-color-service/spec.md
@@ -0,0 +1,62 @@
+# Material Color Service
+
+## Goal
+
+Unify Monet seed extraction, wallpaper color extraction, semantic color roles, host material surfaces, and plugin appearance snapshots behind one host-owned material/color source of truth.
+
+## Scope
+
+- Host service: `IMaterialColorService`
+- Compatibility facade: `IAppearanceThemeService`
+- Settings page: `MaterialColorSettingsPage`
+- Persisted settings:
+ - `ThemeColorMode`
+ - `ThemeColor`
+ - `SelectedWallpaperSeed`
+ - `SystemMaterialMode`
+ - `ThemeWallpaperColorSource`
+ - `UseNativeWallpaperChangeEvents`
+ - `SystemWallpaperRefreshIntervalSeconds`
+- Plugin read-only appearance snapshot fields:
+ - accent color
+ - seed color
+ - color source
+ - system material mode
+ - semantic color roles
+ - material surfaces
+ - wallpaper seed candidates
+
+## Behavior
+
+`IMaterialColorService` owns the live `MaterialColorSnapshot`. Consumers should derive colors and material values from this snapshot instead of recalculating from raw theme settings, wallpaper settings, or `MonetPalette`.
+
+Supported color sources:
+
+- `default_neutral`: stable neutral surfaces with the default accent.
+- `seed_monet`: user-selected seed color processed through Monet.
+- `wallpaper_monet`: wallpaper colors processed through Monet.
+
+Wallpaper color source selection:
+
+- `auto`: app wallpaper or app solid color first, then system wallpaper, then fallback.
+- `app`: app wallpaper or app solid color only, then fallback.
+- `system`: system wallpaper only, then fallback.
+
+System wallpaper monitoring:
+
+- Native Windows user preference events are preferred when enabled and available.
+- Polling remains active as the fallback path.
+- Manual refresh clears cached wallpaper candidates and rebuilds the snapshot.
+
+## Refactor Rules
+
+- New consumers must depend on `IMaterialColorService`, not on parallel combinations of theme settings, wallpaper settings, and `MonetColorService`.
+- `MonetColorService` remains the extraction/palette utility, not the application-wide coordinator.
+- Component/editor/plugin appearance code must consume `MaterialColorSnapshot` or a mapper produced from it.
+- Existing `IAppearanceThemeService` remains available for compatibility, but it must not become a second source of truth.
+
+## Out Of Scope
+
+- Plugin write access to global host appearance settings.
+- Market metadata or sample plugin changes.
+- Replacing the wallpaper picker page. It remains the asset/source management page.
diff --git a/.trae/specs/material-color-service/tasks.md b/.trae/specs/material-color-service/tasks.md
new file mode 100644
index 0000000..34f8121
--- /dev/null
+++ b/.trae/specs/material-color-service/tasks.md
@@ -0,0 +1,13 @@
+# Material Color Service Tasks
+
+- [x] Add unified material/color snapshot models and `IMaterialColorService`.
+- [x] Persist wallpaper color source and native wallpaper event preference.
+- [x] Add the Material & Color settings page.
+- [x] Keep Appearance focused on theme mode, window chrome, and corner radius.
+- [x] Route plugin appearance snapshots through the material/color snapshot.
+- [x] Route component editor theming through the material/color snapshot.
+- [x] Remove legacy color/material preview and save logic from the Appearance page view model.
+- [x] Replace legacy positional `ThemeAppearanceSettingsState` writes with preserving `with` updates where found.
+- [x] Keep native wallpaper events optional with polling/manual refresh fallback.
+- [x] Add regression tests for normalization, plugin mapping, and component editor palette mapping.
+- [ ] Continue retiring legacy direct consumers of raw theme/wallpaper/Monet tuples when they are touched.
diff --git a/.trae/specs/pdc-incremental-migration/checklist.md b/.trae/specs/pdc-incremental-migration/checklist.md
index 9212ae9..d53b8a5 100644
--- a/.trae/specs/pdc-incremental-migration/checklist.md
+++ b/.trae/specs/pdc-incremental-migration/checklist.md
@@ -1,13 +1,16 @@
# Checklist
-- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
-- [ ] `release.yml` uploads app payload artifacts for PDCC.
-- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
-- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
-- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
-- [ ] Host can persist PDC payload into launcher incoming directory.
-- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
-- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
+- [x] `release.yml` does not invoke Velopack.
+- [x] `plonds-build.yml` uploads app payload artifacts and generates PloNDS delta/static outputs.
+- [x] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
+- [x] CI workflow expects `repo/`, `meta/`, `manifests/`, and `installers/` outputs after a release run.
+- [x] Host update source keeps compatibility (`pdc`/`stcn` normalize to active PloNDS source).
+- [x] Host can persist PloNDS payload into launcher incoming directory.
+- [x] Launcher can apply PloNDS FileMap payload with signature/hash verification.
+- [x] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
+- [x] Launcher keeps rollback-capable deployments after successful update.
+- [x] Manual rollback returns a structured failure when the snapshot source directory is missing.
- [ ] CI run attached proving all release matrix jobs pass.
-- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
-- [ ] Rollback verification report attached.
+- [x] N-1 -> N incremental update verified locally on Windows x64.
+- [ ] N-1 -> N incremental update verified on Windows x86 and Linux x64.
+- [x] Rollback regression tests attached in `LanMountainDesktop.Tests`.
diff --git a/.trae/specs/pdc-incremental-migration/spec.md b/.trae/specs/pdc-incremental-migration/spec.md
index b9dfecb..16a549b 100644
--- a/.trae/specs/pdc-incremental-migration/spec.md
+++ b/.trae/specs/pdc-incremental-migration/spec.md
@@ -12,29 +12,33 @@ Replace VeloPack-based incremental packaging with a unified PDC FileMap + object
## Stage 2 (Current Implementation Target)
-- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
-- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
+- Use GitHub Actions PloNDS static publishing as the active incremental path.
+- Keep `phainon.yml` for future PDCC parity, but do not rely on PDCC for the current release flow.
+- Promote PloNDS-distributed FileMap/object-repo as the primary incremental path.
- Keep GitHub Release installers and metadata as parallel distribution.
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
-- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
+- Check updates in order: NS3/PloNDS static source, GitHub Release PloNDS assets, then GitHub full installer.
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
+- Public object URLs come from `S3_PUBLIC_BASE_URL`; do not infer them from `S3_ENDPOINT` and `S3_BUCKET`.
Expected S3 layout:
- - `lanmountain/update/repo//`
- - `lanmountain/update/meta/channels///latest.json`
- - `lanmountain/update/meta/distributions//*.json`
- - `lanmountain/update/installers///*`
+ - `lanmountain/update/repo/sha256//`
+ - `lanmountain/update/meta/channels///latest.json`
+ - `lanmountain/update/meta/distributions/.json`
+ - `lanmountain/update/manifests//plonds-filemap.json`
+ - `lanmountain/update/manifests//plonds-filemap.json.sig`
+ - `lanmountain/update/installers///*`
## Acceptance
-- `release.yml` includes PDCC publish steps and no Velopack steps.
+- `release.yml` contains no Velopack steps; PloNDS static publishing is handled by `plonds-build.yml` and `ddss-publish.yml`.
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
-- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
-- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
+- PloNDS metadata + FileMap + object repo are published under `lanmountain/update/`.
+- Host can consume the NS3/PloNDS static payload and fallback to GitHub when unavailable.
- Launcher can apply both:
- legacy signed `files.json + update.zip`
- - PDC FileMap object-repo payload.
-- Rollback semantics remain unchanged.
+ - PloNDS FileMap object-repo payload.
+- Rollback semantics keep both automatic failure rollback and manual rollback after a successful update.
## Deprecated Notes
diff --git a/.trae/specs/pdc-incremental-migration/tasks.md b/.trae/specs/pdc-incremental-migration/tasks.md
index 02998af..f195f02 100644
--- a/.trae/specs/pdc-incremental-migration/tasks.md
+++ b/.trae/specs/pdc-incremental-migration/tasks.md
@@ -3,13 +3,19 @@
- [x] Remove VeloPack packaging from release workflow.
- [x] Keep signed FileMap path as interim compatibility fallback.
- [x] Remove launcher/runtime Velopack branching.
-- [ ] Add `phainon.yml` for PDCC publish configuration.
-- [ ] Add PDCC installation + publish steps in `release.yml`.
-- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
-- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
-- [ ] Mirror installers to `lanmountain/update/installers///`.
-- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
-- [ ] Add PDC payload model into host update check result.
-- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
-- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
-- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.
+- [x] Add `phainon.yml` for PDCC publish configuration.
+- [ ] Add PDCC installation + publish steps in `release.yml` (deferred; active path is GitHub Actions PloNDS static publish).
+- [x] Upload app payload artifacts for PloNDS delta generation in release build jobs.
+- [x] Publish PloNDS metadata + sha256 object repo to S3 path root `lanmountain/update/`.
+- [x] Mirror installers to `lanmountain/update/installers///`.
+- [x] Keep update source compatibility (`pdc`/`stcn` normalize to active PloNDS source).
+- [x] Add PloNDS static payload model into host update check result.
+- [x] Add host download path for PloNDS payload (`plonds-filemap.json` + signature + object repo).
+- [x] Add launcher PloNDS FileMap apply path with rollback-compatible semantics.
+- [x] Keep old `files.json + update.zip` path behind compatibility fallback.
+- [x] Keep rollback deployment directories after successful apply and prune by bounded retention.
+- [x] Return structured failure when manual rollback snapshot source is missing.
+- [x] Verify static S3 layout, filemap/signature, distribution, latest pointer, and at least one object in CI workflows.
+- [x] Add regression tests for PloNDS success rollback, hash-failure auto rollback, missing rollback source, static NS3 manifest, and manifest field mapping.
+- [ ] Attach live CI run proving the full release matrix passes.
+- [ ] Verify N-1 -> N incremental update on Windows x86 and Linux x64 in release artifacts.
diff --git a/.trae/specs/settings-window-fluent-shell-redesign/spec.md b/.trae/specs/settings-window-fluent-shell-redesign/spec.md
new file mode 100644
index 0000000..709207d
--- /dev/null
+++ b/.trae/specs/settings-window-fluent-shell-redesign/spec.md
@@ -0,0 +1,25 @@
+# Settings Window Fluent Shell Redesign
+
+## Goal
+
+Rebuild the settings window as an independent Fluent shell with a custom titlebar, titlebar hamburger menu, persistent side navigation, search, and Avalonia-standard system material support.
+
+## Requirements
+
+- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
+- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
+- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
+- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
+- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
+- Add `auto` system material mode and make it the default.
+- Implement material with Avalonia `TransparencyLevelHint` only.
+- Preserve settings page layout as direct `ScrollViewer -> StackPanel -> FASettingsExpander` content.
+- Follow `docs/VISUAL_SPEC.md`, `docs/CORNER_RADIUS_SPEC.md`, and `docs/ai/SETTINGS_WINDOW_DESIGN.md`.
+
+## Acceptance
+
+- `dotnet build LanMountainDesktop.slnx -c Debug` succeeds.
+- `dotnet test LanMountainDesktop.slnx -c Debug` succeeds or any unrelated failures are documented.
+- The settings window can navigate by sidebar, titlebar Back, titlebar pane toggle, and search.
+- Appearance settings expose Auto, None, Mica, and/or Acrylic according to system support.
+- Existing dirty user changes are not reverted.
diff --git a/.trae/specs/settings-window-fluent-shell-redesign/tasks.md b/.trae/specs/settings-window-fluent-shell-redesign/tasks.md
new file mode 100644
index 0000000..480e365
--- /dev/null
+++ b/.trae/specs/settings-window-fluent-shell-redesign/tasks.md
@@ -0,0 +1,13 @@
+# Tasks
+
+- [x] Analyze current `SettingsWindow`, appearance theme service, and existing settings page layout.
+- [x] Compare ClassIsland `SettingsWindowNew` and SecRandom v3 Avalonia `SettingsView`.
+- [x] Replace footer fallback pane toggle with titlebar pane toggle.
+- [x] Add titlebar Back, search, restart, and more-options controls.
+- [x] Add settings navigation history.
+- [x] Add settings search service and result highlight.
+- [x] Add `auto` system material mode and Avalonia `TransparencyLevelHint` priority.
+- [x] Update appearance settings options and localization.
+- [x] Add focused tests for material normalization and search filtering.
+- [x] Add design/spec documentation.
+- [ ] Run full app manually on Windows 11 and Windows 10 to verify actual Mica/Acrylic backdrops.
diff --git a/.trae/specs/update-settings-fluent-controls/spec.md b/.trae/specs/update-settings-fluent-controls/spec.md
new file mode 100644
index 0000000..5ae7589
--- /dev/null
+++ b/.trae/specs/update-settings-fluent-controls/spec.md
@@ -0,0 +1,25 @@
+# Update Settings Fluent Controls
+
+## Goal
+
+Make the Settings > Update page the single user-facing control surface for the host update flow.
+
+## Requirements
+
+- The page uses Fluent Avalonia settings controls for update status, release facts, update behavior, and transfer controls.
+- Users can choose update channel, download source, update mode, and download thread count.
+- Update mode options are:
+ - Manual: do not automatically download or install.
+ - Silent Download: check and download in the background, then wait for user installation confirmation.
+ - Silent Install: check and download in the background, then apply when the app exits.
+- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
+- The page displays whether the current payload is an incremental update or reinstall/full installer.
+- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
+- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged.
+
+## Acceptance
+
+- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel.
+- `UpdateSettingsState` persists forced reinstall alongside other update preferences.
+- Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply.
+- Build succeeds for `LanMountainDesktop.slnx`.
diff --git a/.trae/specs/window-layer-isolation/checklist.md b/.trae/specs/window-layer-isolation/checklist.md
new file mode 100644
index 0000000..480d190
--- /dev/null
+++ b/.trae/specs/window-layer-isolation/checklist.md
@@ -0,0 +1,7 @@
+# Checklist
+
+- [x] Air APP window code does not call fused desktop bottom-most APIs.
+- [x] Air APP window code does not set `Topmost = true`.
+- [x] Fused desktop overlay and widget windows still use bottom-most APIs.
+- [x] Fused desktop widget reload path refreshes desktop layer after showing.
+- [ ] Manual Windows z-order verification with fused desktop and Air APP windows.
diff --git a/.trae/specs/window-layer-isolation/spec.md b/.trae/specs/window-layer-isolation/spec.md
new file mode 100644
index 0000000..ae6cf9a
--- /dev/null
+++ b/.trae/specs/window-layer-isolation/spec.md
@@ -0,0 +1,18 @@
+# Window Layer Isolation
+
+## Goal
+
+Keep fused desktop component windows and Air APP windows in separate z-order roles.
+
+## Behavior
+
+- Fused desktop windows are desktop-surface windows. They may use `IWindowBottomMostService` and region passthrough, must stay attached to the Windows desktop icon host when supported, and must not cover ordinary apps.
+- Air APP windows are ordinary application windows. They must not use the fused desktop bottom-most service, must not attach to the desktop icon host, and must not use global `Topmost` promotion.
+- Re-showing or reloading fused desktop widgets refreshes their desktop layer after the window is visible.
+- Air APP activation uses normal window activation; repeated-open foreground recovery remains owned by Launcher lifecycle activation.
+
+## Out of Scope
+
+- Changing Air APP lifecycle IPC.
+- Changing whiteboard note sharing.
+- Implementing third-party Air APP SDK behavior.
diff --git a/.trae/specs/window-layer-isolation/tasks.md b/.trae/specs/window-layer-isolation/tasks.md
new file mode 100644
index 0000000..bf393e5
--- /dev/null
+++ b/.trae/specs/window-layer-isolation/tasks.md
@@ -0,0 +1,7 @@
+# Tasks
+
+- [x] Remove Air APP `Topmost` promotion from `AirAppWindow`.
+- [x] Add explicit desktop-layer refresh for fused desktop widget windows.
+- [x] Refresh fused desktop widget windows after show/reload.
+- [x] Add window-role diagnostics for desktop-surface and Air APP windows.
+- [x] Add static regression tests for window-layer isolation.
diff --git a/CODE_WIKI.md b/CODE_WIKI.md
new file mode 100644
index 0000000..1d0712d
--- /dev/null
+++ b/CODE_WIKI.md
@@ -0,0 +1,874 @@
+# LanMountainDesktop Code Wiki
+
+> 本文档是 LanMountainDesktop(阑山桌面)项目的结构化 Code Wiki,涵盖项目整体架构、主要模块职责、关键类与函数说明、依赖关系以及项目运行方式等关键信息。
+>
+> 生成日期:2026-05-07
+> 产品版本:1.0.0
+> Plugin SDK API 基线:5.0.0
+
+---
+
+## 目录
+
+1. [项目概述](#1-项目概述)
+2. [整体架构](#2-整体架构)
+3. [项目结构与模块职责](#3-项目结构与模块职责)
+4. [关键类与函数说明](#4-关键类与函数说明)
+5. [依赖关系](#5-依赖关系)
+6. [项目运行方式](#6-项目运行方式)
+7. [启动流程详解](#7-启动流程详解)
+8. [插件系统架构](#8-插件系统架构)
+9. [数据流与交互模型](#9-数据流与交互模型)
+10. [测试体系](#10-测试体系)
+
+---
+
+## 1. 项目概述
+
+### 1.1 产品定位
+
+**阑山桌面(LanMountainDesktop)** 是一款跨平台桌面环境增强工具,基于 Avalonia UI 和 .NET 10 构建。
+
+- **产品口号**:你的桌面,不止一面
+- **技术基线**:Avalonia UI + .NET 10
+- **支持平台**:Windows、Linux、macOS
+- **仓库角色**:桌面宿主、插件运行时、Plugin SDK 与共享契约的权威来源
+
+### 1.2 目标用户
+
+- **学生用户**:课程表、自习监测、计时、天气和日常信息聚合
+- **办公用户**:日历、资讯、最近文档、常用工具入口
+- **效率和美化爱好者**:自由布局、主题切换、插件扩展
+- **中文用户**:本地化界面、农历和节假日等本地语境支持
+
+### 1.3 核心能力
+
+- **桌面组件系统**:内置组件与扩展组件统一注册、统一放置约束
+- **插件系统**:宿主加载插件、整合设置页、组件与市场安装流
+- **外观系统**:主题、玻璃层级、圆角与颜色资源统一管理
+- **设置系统**:独立设置窗口、设置页注册与分域持久化
+- **跨平台运行**:基于 Avalonia 的桌面宿主运行在 Windows、Linux、macOS
+
+### 1.4 生态边界
+
+| 仓库 | 职责 |
+|------|------|
+| `LanMountainDesktop`(本仓库) | 宿主代码、插件运行时、SDK、共享契约、主题与设置基础设施 |
+| `LanAirApp`(兄弟仓库) | 插件市场元数据、开发者生态材料 |
+| `LanMountainDesktop.SamplePlugin` | 官方示例插件实现 |
+
+---
+
+## 2. 整体架构
+
+### 2.1 架构分层
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 用户界面层 (UI Layer) │
+│ Views/ │ ViewModels/ │ Theme/ │ Styles/ │ Localization/
+├─────────────────────────────────────────────────────────────┤
+│ 业务服务层 (Service Layer) │
+│ Services/ │ ComponentSystem/ │ DesktopEditing/ │ plugins/
+├─────────────────────────────────────────────────────────────┤
+│ 基础设施层 (Infrastructure) │
+│ DesktopHost/ │ Appearance/ │ Settings.Core/ │ Shared.IPC/
+├─────────────────────────────────────────────────────────────┤
+│ 抽象与契约层 (Abstractions) │
+│ Host.Abstractions/ │ Shared.Contracts/ │ PluginSdk/
+├─────────────────────────────────────────────────────────────┤
+│ 启动与更新层 (Launcher) │
+│ LanMountainDesktop.Launcher/ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 2.2 核心设计原则
+
+1. **插件优先**:核心功能通过插件扩展,宿主提供运行时和基础设施
+2. **组件化桌面**:所有桌面元素都是组件,统一注册、统一放置
+3. **设置分域**:App / Launcher / ComponentInstance / Plugin 四级设置作用域
+4. **主题动态化**:支持 Material Design 3 动态配色、系统主题跟随
+5. **进程隔离预留**:当前为进程内加载,预留了隔离进程架构
+
+---
+
+## 3. 项目结构与模块职责
+
+### 3.1 解决方案项目列表
+
+| 项目路径 | 输出类型 | 主要职责 |
+|---------|---------|---------|
+| `LanMountainDesktop/` | WinExe | 主桌面宿主应用,包含 UI、服务、组件系统、插件运行时接入 |
+| `LanMountainDesktop.Launcher/` | WinExe | 启动器 - 负责 OOBE、Splash、版本管理、增量更新、插件安装 |
+| `LanMountainDesktop.PluginSdk/` | Library | 官方插件 SDK,定义插件可依赖的公开接口与打包行为 |
+| `LanMountainDesktop.Shared.Contracts/` | Library | 宿主与插件共享的稳定契约类型 |
+| `LanMountainDesktop.Shared.IPC/` | Library | 统一 IPC 基础,用于 Host 公共服务、Launcher/OOBE 启动通知、插件贡献的公共服务 |
+| `LanMountainDesktop.Appearance/` | Library | 主题、圆角、外观资源相关基础设施 |
+| `LanMountainDesktop.Settings.Core/` | Library | 设置域、持久化和设置基础抽象 |
+| `LanMountainDesktop.DesktopHost/` | Library | 桌面宿主流程与生命周期相关逻辑 |
+| `LanMountainDesktop.DesktopComponents.Runtime/` | Library | 组件运行时支撑能力 |
+| `LanMountainDesktop.Host.Abstractions/` | Library | 宿主侧抽象接口 |
+| `LanMountainDesktop.PluginIsolation.Contracts/` | Library | 插件隔离机制的传输无关 DTO、路由常量、错误码 |
+| `LanMountainDesktop.PluginIsolation.Ipc/` | Library | 插件隔离 IPC 外观,基于 dotnetCampus.Ipc |
+| `LanMountainDesktop.PluginTemplate/` | Library | `dotnet new lmd-plugin` 官方模板 |
+| `LanMountainDesktop.PluginUpgradeHelper/` | Library | 插件升级帮助程序 |
+| `LanMountainDesktop.Tests/` | Test | 宿主与 SDK 的测试项目 |
+
+### 3.2 主宿主工程内部结构
+
+```
+LanMountainDesktop/
+├── Program.cs # 进程启动主线
+├── App.axaml.cs # 应用初始化、主题、语言、托盘、插件运行时
+├── Views/ # 界面视图
+│ ├── MainWindow.axaml # 主窗口
+│ ├── SettingsWindow.axaml # 设置窗口
+│ ├── ComponentLibraryWindow.axaml # 组件库窗口
+│ ├── FusedDesktopComponentLibraryWindow.axaml # 融合桌面组件库
+│ ├── NotificationWindow.axaml # 通知窗口
+│ ├── TransparentOverlayWindow.axaml # 透明覆盖层窗口
+│ ├── SettingsPages/ # 设置页面
+│ ├── Components/ # 桌面组件视图
+│ └── ComponentEditors/ # 组件编辑器视图
+├── ViewModels/ # 视图模型
+│ ├── MainWindowViewModel.cs
+│ ├── ViewModelBase.cs
+│ └── ...
+├── Services/ # 业务服务层
+│ ├── AppearanceThemeService.cs # 外观主题服务
+│ ├── Settings/ # 设置相关服务
+│ ├── MaterialColorService.cs # Material 颜色服务
+│ ├── DesktopTrayService.cs # 桌面托盘服务
+│ ├── FusedDesktopManagerService.cs # 融合桌面管理
+│ └── ...
+├── ComponentSystem/ # 组件系统
+│ ├── ComponentRegistry.cs # 组件注册表
+│ ├── DesktopComponentDefinition.cs # 组件定义
+│ └── ...
+├── plugins/ # 插件运行时
+│ ├── PluginRuntimeService.cs # 插件运行时服务
+│ ├── PluginLoader.cs # 插件加载器
+│ └── ...
+├── Theme/ # 主题资源
+├── Styles/ # 样式规则
+├── DesktopEditing/ # 桌面布局编辑
+├── Localization/ # 本地化资源
+└── Models/ # 数据模型
+```
+
+### 3.3 Launcher 工程结构
+
+```
+LanMountainDesktop.Launcher/
+├── Program.cs # 启动器入口
+├── App.axaml.cs # 启动器应用初始化
+├── Views/ # 启动器视图
+│ ├── OobeWindow.axaml # 首次体验窗口
+│ └── SplashWindow.axaml # 启动动画窗口
+└── Services/ # 启动器服务
+ ├── DeploymentLocator.cs # 版本目录定位
+ ├── UpdateCheckService.cs # 更新检查
+ ├── UpdateEngineService.cs # 更新引擎
+ ├── LauncherFlowCoordinator.cs # 流程协调器
+ ├── OobeStateService.cs # OOBE 状态管理
+ ├── PluginInstallerService.cs # 插件安装
+ └── PluginUpgradeQueueService.cs # 插件升级队列
+```
+
+---
+
+## 4. 关键类与函数说明
+
+### 4.1 应用程序入口与生命周期
+
+#### `Program`(LanMountainDesktop/Program.cs)
+
+**职责**:应用程序入口点,负责启动初始化、单实例控制、资源加载、渲染模式配置、日志初始化。
+
+**关键属性**:
+
+```csharp
+internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
+```
+
+**关键方法**:
+
+| 方法 | 签名 | 说明 |
+|------|------|------|
+| `Main` | `public static void Main(string[] args)` | 应用入口,初始化日志、单实例、遥测,构建 Avalonia AppBuilder |
+| `BuildAvaloniaApp` | `public static AppBuilder BuildAvaloniaApp(string renderMode)` | 构建 Avalonia 应用,配置 Win32 渲染模式 |
+| `AcquireSingleInstance` | `private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)` | 获取单实例锁,支持重启场景 |
+| `LoadConfiguredRenderMode` | `private static string LoadConfiguredRenderMode()` | 从设置加载配置的渲染模式 |
+| `RegisterGlobalExceptionLogging` | `private static void RegisterGlobalExceptionLogging()` | 注册全局未处理异常日志和遥测 |
+
+#### `App`(LanMountainDesktop/App.axaml.cs)
+
+**职责**:应用启动和生命周期管理,包含应用初始化、主窗口管理、插件运行时初始化、主题设置、设置系统初始化。
+
+**关键属性**:
+
+```csharp
+internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
+internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle { get; }
+internal static INotificationService? CurrentNotificationService { get; }
+public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
+public ISettingsFacadeService SettingsFacade => _settingsFacade;
+```
+
+**关键方法**:
+
+| 方法 | 签名 | 说明 |
+|------|------|------|
+| `Initialize` | `public override void Initialize()` | 初始化应用资源、主题、语言、设置服务 |
+| `OnFrameworkInitializationCompleted` | `public override void OnFrameworkInitializationCompleted()` | 框架初始化完成后调用,初始化 IPC、桌面壳层 |
+| `InitializeDesktopShell` | `private void InitializeDesktopShell()` | 初始化桌面壳层,包括插件运行时、托盘、主窗口 |
+| `OpenIndependentSettingsModule` | `internal void OpenIndependentSettingsModule(string source, string? pageTag)` | 打开独立设置窗口 |
+| `ActivateMainWindow` | `internal void ActivateMainWindow()` | 激活主窗口 |
+
+### 4.2 插件系统
+
+#### `PluginRuntimeService`(LanMountainDesktop/plugins/PluginRuntimeService.cs)
+
+**职责**:插件系统的核心运行时类,负责插件的加载、卸载、管理、依赖注入、插件贡献点注册。
+
+**关键属性**:
+
+```csharp
+public string PluginsDirectory { get; } // 插件目录路径
+public IReadOnlyList LoadedPlugins { get; } // 已加载插件列表
+public IReadOnlyList LoadResults { get; } // 加载结果列表
+public IReadOnlyList Catalog { get; } // 插件目录
+public IReadOnlyList SettingsSections { get; } // 设置页贡献
+public IReadOnlyList DesktopComponents { get; } // 组件贡献
+public IReadOnlyList DesktopComponentEditors { get; } // 编辑器贡献
+```
+
+**关键方法**:
+
+| 方法 | 签名 | 说明 |
+|------|------|------|
+| `LoadInstalledPlugins` | `public void LoadInstalledPlugins()` | 加载所有已安装插件 |
+| `SetPluginEnabled` | `public bool SetPluginEnabled(string pluginId, bool isEnabled)` | 启用/禁用插件 |
+| `InstallPluginPackage` | `public PluginManifest InstallPluginPackage(string packagePath)` | 安装插件包(.laapp) |
+| `DeleteInstalledPlugin` | `public bool DeleteInstalledPlugin(string pluginId)` | 删除已安装插件 |
+
+#### `IPlugin`(LanMountainDesktop.PluginSdk/IPlugin.cs)
+
+**职责**:插件接口,定义了插件的基本生命周期和能力。插件必须实现此接口以被宿主识别和加载。
+
+```csharp
+public interface IPlugin
+{
+ void Initialize(HostBuilderContext context, IServiceCollection services);
+}
+```
+
+#### `PluginBase`(LanMountainDesktop.PluginSdk/PluginBase.cs)
+
+**职责**:插件基类,提供了插件开发的基础实现。
+
+```csharp
+public abstract class PluginBase : IPlugin
+{
+ public virtual void Initialize(HostBuilderContext context, IServiceCollection services) { }
+}
+```
+
+#### `PluginManifest`(LanMountainDesktop.PluginSdk/PluginManifest.cs)
+
+**职责**:插件清单信息类,包含插件的元数据。
+
+```csharp
+public sealed record PluginManifest(
+ string Id, // 插件唯一标识
+ string Name, // 插件名称
+ string EntranceAssembly, // 入口程序集
+ string? Description = null, // 描述
+ string? Author = null, // 作者
+ string? Version = null, // 版本
+ string? ApiVersion = null, // API 版本
+ IReadOnlyList? SharedContracts = null,
+ PluginRuntimeConfiguration? Runtime = null)
+```
+
+**关键方法**:
+
+| 方法 | 签名 | 说明 |
+|------|------|------|
+| `Load` | `public static PluginManifest Load(string manifestPath)` | 从文件加载插件清单 |
+| `ResolveEntranceAssemblyPath` | `public string ResolveEntranceAssemblyPath(string manifestPath)` | 解析入口程序集路径 |
+
+### 4.3 设置系统
+
+#### `SettingsService`(LanMountainDesktop/Services/Settings/SettingsService.cs)
+
+**职责**:设置系统的核心服务,管理应用和插件的设置数据持久化、读取和保存、设置变更监听。
+
+**关键属性**:
+
+```csharp
+public event EventHandler? Changed; // 设置变更事件
+```
+
+**关键方法**:
+
+| 方法 | 签名 | 说明 |
+|------|------|------|
+| `LoadSnapshot` | `public T LoadSnapshot(SettingsScope scope, string? subjectId = null, string? placementId = null)` | 加载设置快照 |
+| `SaveSnapshot` | `public void SaveSnapshot(SettingsScope scope, T snapshot, ...)` | 保存设置快照 |
+| `LoadSection` | `public T LoadSection(SettingsScope scope, string subjectId, string sectionId, ...)` | 加载设置节 |
+| `SaveSection` | `public void SaveSection(SettingsScope scope, string subjectId, string sectionId, T section, ...)` | 保存设置节 |
+| `GetValue` | `public T? GetValue(SettingsScope scope, string key, ...)` | 获取单个值 |
+| `SetValue` | `public void SetValue(SettingsScope scope, string key, T value, ...)` | 设置单个值 |
+| `GetComponentAccessor` | `public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)` | 获取组件设置访问器 |
+
+**设置作用域(SettingsScope)**:
+
+| 作用域 | 说明 |
+|--------|------|
+| `App` | 应用级设置 |
+| `Launcher` | 启动器设置 |
+| `ComponentInstance` | 组件实例设置 |
+| `Plugin` | 插件设置 |
+
+### 4.4 外观主题系统
+
+#### `IAppearanceThemeService`(LanMountainDesktop/Services/AppearanceThemeService.cs)
+
+**职责**:外观主题服务接口,定义了主题获取、预览构建、资源应用等方法。
+
+```csharp
+public interface IAppearanceThemeService
+{
+ AppearanceThemeSnapshot GetCurrent();
+ AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState);
+ event EventHandler? Changed;
+ void ApplyThemeResources(IResourceDictionary resources);
+ AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role);
+ void ApplyWindowMaterial(Window window, MaterialSurfaceRole role);
+}
+```
+
+#### `AppearanceThemeService`
+
+**职责**:外观主题服务的实现,委托给 `MaterialColorService` 处理具体逻辑。
+
+**关键方法**:
+
+| 方法 | 签名 | 说明 |
+|------|------|------|
+| `GetCurrent` | `public AppearanceThemeSnapshot GetCurrent()` | 获取当前主题快照 |
+| `BuildPreview` | `public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState)` | 构建主题预览 |
+| `ApplyThemeResources` | `public void ApplyThemeResources(IResourceDictionary resources)` | 应用主题资源到资源字典 |
+| `GetMaterialSurface` | `public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)` | 获取材质表面配置 |
+| `ApplyWindowMaterial` | `public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)` | 应用窗口材质效果 |
+
+**材质表面角色(MaterialSurfaceRole)**:
+
+| 角色 | 说明 |
+|------|------|
+| `WindowBackground` | 窗口背景 |
+| `SettingsWindowBackground` | 设置窗口背景 |
+| `DockBackground` | 停靠栏背景 |
+| `StatusBarBackground` | 状态栏背景 |
+| `DesktopComponentHost` | 桌面组件宿主 |
+| `StatusBarComponentHost` | 状态栏组件宿主 |
+| `OverlayPanel` | 覆盖层面板 |
+
+### 4.5 桌面宿主
+
+#### `DesktopBootstrap`(LanMountainDesktop.DesktopHost/DesktopBootstrap.cs)
+
+**职责**:桌面启动引导,协调启动服务初始化和应用初始化。
+
+```csharp
+public static class DesktopBootstrap
+{
+ public static void InitializeStartupServices(
+ Action initializeTelemetryIdentity,
+ Action initializeCrashTelemetry,
+ Action initializeUsageTelemetry,
+ Action scheduleStartupCleanup);
+
+ public static void InitializeApplication(Application application, Action initializeShell);
+}
+```
+
+### 4.6 Launcher 核心服务
+
+#### `DeploymentLocator`(LanMountainDesktop.Launcher/Services/DeploymentLocator.cs)
+
+**职责**:扫描和定位 `app-*` 版本目录,选择最佳版本。
+
+**版本选择算法**:
+1. 扫描所有 `app-*` 目录
+2. 过滤掉带 `.destroy` 或 `.partial` 标记的目录
+3. 优先选择带 `.current` 标记的版本
+4. 如果没有 `.current`,选择版本号最高的
+
+#### `UpdateEngineService`
+
+**职责**:下载、验证、应用增量更新,支持原子化更新和回滚。
+
+#### `LauncherFlowCoordinator`
+
+**职责**:协调 OOBE → Splash → 更新 → 插件 → 启动主程序的完整流程。
+
+---
+
+## 5. 依赖关系
+
+### 5.1 项目间依赖图
+
+```
+LanMountainDesktop (主程序)
+├── LanMountainDesktop.Host.Abstractions
+├── LanMountainDesktop.Shared.Contracts
+├── LanMountainDesktop.Shared.IPC
+├── LanMountainDesktop.Settings.Core
+├── LanMountainDesktop.Appearance
+├── LanMountainDesktop.DesktopComponents.Runtime
+├── LanMountainDesktop.DesktopHost
+├── LanMountainDesktop.PluginSdk
+└── ThirdParty/DotNetCampus.InkCanvas
+
+LanMountainDesktop.Launcher (启动器)
+├── LanMountainDesktop.Shared.Contracts
+├── LanMountainDesktop.Shared.IPC
+└── LanMountainDesktop.Settings.Core
+
+LanMountainDesktop.PluginSdk (插件SDK)
+└── (无项目引用,纯公共接口)
+
+LanMountainDesktop.DesktopHost
+├── LanMountainDesktop.Host.Abstractions
+└── LanMountainDesktop.Shared.Contracts
+
+LanMountainDesktop.Appearance
+├── LanMountainDesktop.Settings.Core
+└── LanMountainDesktop.Shared.Contracts
+
+LanMountainDesktop.DesktopComponents.Runtime
+├── LanMountainDesktop.Host.Abstractions
+└── LanMountainDesktop.Shared.Contracts
+
+LanMountainDesktop.PluginIsolation.Ipc
+├── LanMountainDesktop.PluginIsolation.Contracts
+└── LanMountainDesktop.Shared.IPC
+```
+
+### 5.2 主要 NuGet 依赖
+
+| 包名 | 版本 | 用途 |
+|------|------|------|
+| Avalonia | 12.0.2 | 跨平台 UI 框架 |
+| Avalonia.Controls.WebView | 12.0.0 | WebView 控件 |
+| Avalonia.Desktop | 12.0.2 | 桌面平台支持 |
+| Avalonia.Themes.Fluent | 12.0.2 | Fluent 主题 |
+| FluentAvaloniaUI | 3.0.0-preview2 | Fluent UI 控件库 |
+| Material.Avalonia | 3.16.1 | Material Design 控件 |
+| MaterialColorUtilities | 0.3.0 | Material Design 3 动态配色 |
+| CommunityToolkit.Mvvm | 8.4.2 | MVVM 工具包 |
+| Microsoft.Extensions.DependencyInjection | 11.0.0-preview | 依赖注入 |
+| Microsoft.Extensions.Hosting.Abstractions | 11.0.0-preview | 宿主抽象 |
+| Microsoft.Data.Sqlite | 11.0.0-preview | SQLite 数据库 |
+| PostHog | 2.6.0 | 使用遥测 |
+| Sentry | 6.4.1 | 崩溃遥测 |
+| Downloader | 5.4.0 | 文件下载 |
+| Lib.Harmony.Thin | 2.4.2 | 运行时方法拦截 |
+| log4net | 3.3.1 | 日志记录 |
+
+---
+
+## 6. 项目运行方式
+
+### 6.1 环境准备
+
+- 安装 **.NET SDK 10**(由 `global.json` 锁定版本 `10.0.103`)
+- 桌面端建议优先在 Windows 上开发和验证
+- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx`
+
+### 6.2 常用命令
+
+#### 还原与构建
+
+```bash
+dotnet restore
+dotnet build LanMountainDesktop.slnx -c Debug
+```
+
+#### 运行桌面宿主(开发模式)
+
+```bash
+# 直接运行主程序,跳过 Launcher
+dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
+```
+
+#### 运行桌面宿主(生产模式)
+
+```bash
+# 先构建 Launcher
+dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug
+
+# 通过 Launcher 启动主程序
+dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
+```
+
+#### Launcher 其他命令
+
+```bash
+# 检查更新
+dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check
+
+# 安装插件
+dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- plugin install
+
+# 版本回退
+dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
+```
+
+#### 运行测试
+
+```bash
+dotnet test LanMountainDesktop.slnx -c Debug
+```
+
+#### 插件本地包生成
+
+```powershell
+./scripts/Pack-PluginPackages.ps1
+```
+
+### 6.3 Linux 录音依赖
+
+如果在 Linux 上使用录音机或自习监测相关能力,需要安装音频库:
+
+```bash
+# Debian/Ubuntu
+sudo apt install libportaudio2 libasound2
+
+# Fedora/RHEL
+sudo dnf install portaudio-libs alsa-lib
+
+# Arch Linux
+sudo pacman -S portaudio alsa-lib
+
+# Alpine Linux
+sudo apk add portaudio alsa-lib
+```
+
+---
+
+## 7. 启动流程详解
+
+### 7.1 生产环境启动流程(通过 Launcher)
+
+```
+用户启动 LanMountainDesktop.Launcher.exe
+ │
+ ▼
+Launcher 扫描 app-* 目录,选择最佳版本
+(优先 .current 标记,然后按版本号降序)
+ │
+ ▼
+首次启动?→ 显示 OOBE 引导(OobeWindow)
+ │
+ ▼
+显示 Splash 启动动画(SplashWindow)
+ │
+ ▼
+检查并应用待处理的更新(UpdateEngineService.ApplyPendingUpdate)
+ │
+ ▼
+处理插件升级队列(PluginUpgradeQueueService)
+ │
+ ▼
+启动主程序 app-{version}/LanMountainDesktop.exe
+ │
+ ▼
+清理标记为 .destroy 的旧版本
+```
+
+### 7.2 主程序启动流程(LanMountainDesktop.exe)
+
+```
+Program.cs Main()
+ │
+ ├── 初始化日志(AppLogger.Initialize)
+ ├── 初始化应用数据路径(AppDataPathProvider.Initialize)
+ ├── 解析开发插件选项(DevPluginOptions.Parse)
+ ├── 注册全局异常日志
+ └── 获取重启父进程 ID
+ │
+ ▼
+获取单实例锁(SingleInstanceService)
+ │
+ ├── 非主实例?→ 通知主实例并退出
+ └── 是主实例?→ 继续
+ │
+ ▼
+初始化启动服务(DesktopBootstrap.InitializeStartupServices)
+ │
+ ├── 初始化遥测身份(TelemetryIdentityService)
+ ├── 初始化崩溃遥测(SentryCrashTelemetryService)
+ ├── 初始化使用遥测(PostHogUsageTelemetryService)
+ └── 调度白板笔记启动清理
+ │
+ ▼
+运行启动诊断(StartupDiagnosticsService.Run)
+ │
+ ▼
+加载配置的渲染模式(LoadConfiguredRenderMode)
+ │
+ ▼
+构建 Avalonia AppBuilder(BuildAvaloniaApp)
+ │
+ ▼
+进入 App.axaml.cs
+ │
+ ├── 初始化主题(ApplyThemeFromSettings)
+ ├── 初始化语言(ApplyCurrentCultureFromSettings)
+ ├── 初始化设置窗口服务(EnsureSettingsWindowService)
+ ├── 初始化天气定位刷新(EnsureWeatherLocationRefreshService)
+ └── 初始化通知服务(EnsureNotificationService)
+ │
+ ▼
+框架初始化完成(OnFrameworkInitializationCompleted)
+ │
+ ├── 初始化公共 IPC(InitializePublicIpc)
+ ├── 启动单实例激活监听
+ ├── 初始化 Launcher IPC(InitializeLauncherIpcAsync)
+ └── 初始化桌面壳层(InitializeDesktopShell)
+ │
+ ▼
+桌面壳层初始化
+ │
+ ├── 初始化插件运行时(InitializePluginRuntime)
+ ├── 初始化托盘图标(InitializeTrayIcon)
+ ├── 创建主窗口(CreateAndAssignMainWindow)
+ └── 启动天气定位刷新
+```
+
+### 7.3 版本目录结构
+
+```
+安装根目录/
+├── LanMountainDesktop.Launcher.exe ← 唯一入口
+├── app-1.0.0/ ← 版本目录
+│ ├── .current ← 当前版本标记
+│ ├── LanMountainDesktop.exe
+│ └── ...
+├── app-1.0.1/ ← 新版本
+│ ├── .partial ← 下载中标记
+│ └── ...
+└── .launcher/ ← Launcher 数据
+ ├── state/ ← OOBE 状态
+ ├── update/incoming/ ← 更新缓存
+ └── snapshots/ ← 更新快照
+```
+
+**版本标记文件**:
+- `.current` - 标记当前使用的版本
+- `.partial` - 标记下载未完成的版本(更新失败时自动清理)
+- `.destroy` - 标记待删除的旧版本(下次启动时清理)
+
+---
+
+## 8. 插件系统架构
+
+### 8.1 插件生命周期
+
+```
+插件包(.laapp)
+ │
+ ▼
+发现阶段(DiscoverCandidates)
+ │
+ ├── 扫描 PluginsDirectory
+ ├── 解析 plugin.json 清单
+ └── 验证 API 版本兼容性
+ │
+ ▼
+加载阶段(PluginLoader.LoadFromPackage / LoadFromManifest)
+ │
+ ├── 注册共享契约
+ ├── 加载入口程序集
+ ├── 调用 IPlugin.Initialize
+ └── 收集贡献点(设置页、组件、编辑器)
+ │
+ ▼
+激活阶段
+ │
+ ├── 注册设置页到设置窗口
+ ├── 注册组件到组件系统
+ └── 注册编辑器到编辑器系统
+ │
+ ▼
+运行阶段
+ │
+ ├── 插件服务通过 DI 容器解析
+ ├── 插件通过 IPluginContext 访问宿主功能
+ └── 插件通过 IPC 与宿主通信
+ │
+ ▼
+卸载阶段
+ │
+ ├── 卸载插件程序集
+ ├── 清理贡献点
+ └── 释放资源
+```
+
+### 8.2 插件运行时模式
+
+| 模式 | 状态 | 说明 |
+|------|------|------|
+| `in-proc` | 当前默认 | 进程内加载,PluginLoadContext 提供程序集隔离 |
+| `isolated-background` | 预留 | 后台逻辑移至独立工作进程,Host UI 变为薄 IPC 驱动壳 |
+| `isolated-window` | 预留 | 插件 UI 离屏渲染,Host 嵌入平台窗口句柄 |
+
+### 8.3 插件贡献点
+
+插件可以向宿主贡献以下内容:
+
+1. **设置页(Settings Sections)**:通过 `IPluginSettingsService` 注册自定义设置页
+2. **桌面组件(Desktop Components)**:通过组件贡献点注册可放置的桌面组件
+3. **组件编辑器(Component Editors)**:为组件提供自定义编辑器界面
+4. **公共服务(Public Services)**:通过 IPC 向外部提供公共服务
+
+### 8.4 插件目录结构
+
+```
+PluginsDirectory/
+├── PluginA/
+│ ├── plugin.json # 插件清单
+│ ├── PluginA.dll # 入口程序集
+│ └── ... # 其他资源
+├── PluginB.laapp # 打包的插件包
+└── ...
+```
+
+---
+
+## 9. 数据流与交互模型
+
+### 9.1 设置流
+
+```
+Settings.Core(基础设置能力)
+ │
+ ├── 宿主通过 SettingsFacade 读取和监听设置变化
+ ├── 插件通过 IPluginSettingsService 访问设置
+ └── 组件通过 IComponentSettingsAccessor 访问设置
+```
+
+### 9.2 外观流
+
+```
+Appearance(主题和圆角资源)
+ │
+ ├── 宿主在 App.axaml.cs 中应用到资源字典
+ ├── MaterialColorService 处理动态配色
+ └── 主题变更通过事件通知所有订阅者
+```
+
+### 9.3 组件流
+
+```
+ComponentSystem(组件定义、注册、扩展接入)
+ │
+ ├── 内置组件在 ComponentSystem/ 中定义
+ ├── 插件通过贡献点注册扩展组件
+ └── DesktopEditing/ 处理组件放置和布局
+```
+
+### 9.4 插件流
+
+```
+plugins/(宿主侧插件运行时)
+ │
+ ├── .laapp 插件包的发现、安装、替换
+ ├── 插件激活与共享契约装配
+ └── 插件设置页注册到宿主设置窗口
+```
+
+### 9.5 IPC 流
+
+```
+Shared.IPC(统一 IPC 基础)
+ │
+ ├── Host 公共服务
+ ├── Launcher/OOBE 启动通知
+ ├── 插件贡献的公共服务
+ └── 外部集成(External IPC Public API)
+```
+
+---
+
+## 10. 测试体系
+
+### 10.1 测试项目
+
+测试项目 `LanMountainDesktop.Tests/` 覆盖以下方面:
+
+| 测试类 | 覆盖内容 |
+|--------|---------|
+| `CornerRadiusScaleTests.cs` | 圆角和外观缩放 |
+| `DesktopPlacementMathTests.cs` | 桌面布局数学计算 |
+| `DesktopEditCommitMathTests.cs` | 桌面编辑提交计算 |
+| `ComponentSettingsServiceTests.cs` | 组件设置服务 |
+| `UiExceptionGuardTests.cs` | UI 异常保护 |
+| `WhiteboardNotePersistenceServiceTests.cs` | 白板笔记持久化 |
+| `MaterialColorIntegrationTests.cs` | 材质颜色集成 |
+| `OobeStateServiceTests.cs` | OOBE 状态服务 |
+| `PluginInstallerServiceTests.cs` | 插件安装服务 |
+| `PluginUpgradeQueueServiceTests.cs` | 插件升级队列 |
+| `LauncherFlowCoordinatorTests.cs` | 启动器流程协调 |
+| `LauncherBackgroundServiceTests.cs` | 启动器后台服务 |
+| `PluginIpcServerTests.cs` | 插件 IPC 服务端 |
+| `PluginIpcClientTests.cs` | 插件 IPC 客户端 |
+| `HostShutdownGateTests.cs` | 主机关闭门 |
+| `SingleInstanceServiceTests.cs` | 单实例服务 |
+
+### 10.2 测试原则
+
+- 涉及宿主行为、SDK 契约、布局计算或设置持久化的改动,应优先补对应测试
+- 优先扩展已有测试而不是新建无关测试入口
+
+---
+
+## 附录 A:快速参考
+
+### A.1 关键文件速查
+
+| 需求 | 优先查看文件 |
+|------|-------------|
+| 启动问题 | `LanMountainDesktop/Program.cs`, `LanMountainDesktop/App.axaml.cs` |
+| Launcher 启动问题 | `LanMountainDesktop.Launcher/Program.cs`, `Services/LauncherFlowCoordinator.cs` |
+| 版本管理问题 | `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs` |
+| 更新系统问题 | `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`, `UpdateCheckService.cs` |
+| 设置窗口和设置页 | `LanMountainDesktop/Views/`, `ViewModels/`, `Services/Settings/` |
+| 插件加载与安装 | `LanMountainDesktop/plugins/PluginRuntimeService.cs` |
+| 组件元数据或放置规则 | `LanMountainDesktop/ComponentSystem/` |
+| 主题、颜色、圆角 | `LanMountainDesktop/Theme/`, `Styles/`, `LanMountainDesktop.Appearance/` |
+| 设置持久化 | `LanMountainDesktop.Settings.Core/`, `LanMountainDesktop/Services/Settings/SettingsService.cs` |
+| SDK 接口调整 | `LanMountainDesktop.PluginSdk/`, `LanMountainDesktop.Shared.Contracts/` |
+| 桌面壳层或生命周期 | `Program.cs`, `App.axaml.cs`, `LanMountainDesktop.DesktopHost/` |
+
+### A.2 文档权威来源
+
+| 主题 | 权威文档 |
+|------|---------|
+| 产品定位 | `docs/PRODUCT.md` |
+| 架构与模块职责 | `docs/ARCHITECTURE.md` |
+| 运行、构建、测试、打包 | `docs/DEVELOPMENT.md` |
+| 视觉规范 | `docs/VISUAL_SPEC.md` |
+| 圆角规范 | `docs/CORNER_RADIUS_SPEC.md` |
+| 生态边界 | `docs/ECOSYSTEM_BOUNDARIES.md` |
+| SDK v5 迁移 | `docs/PLUGIN_SDK_V5_MIGRATION.md` |
+| 代码地图 | `docs/ai/CODEBASE_MAP.md` |
+| AI 协作入口 | `AGENTS.md` |
+| Feature 规格 | `.trae/specs/` |
+
+---
+
+*本文档基于 LanMountainDesktop 仓库代码和文档自动生成,如有更新请以仓库最新代码为准。*
diff --git a/Directory.Packages.props b/Directory.Packages.props
index aa56451..2678544 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,7 @@
+
@@ -29,7 +30,7 @@
-
+
diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml b/LanMountainDesktop.AirAppHost/AirApp.axaml
new file mode 100644
index 0000000..858ce71
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/AirApp.axaml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MiSans VF, avares://LanMountainDesktop.AirAppHost/Assets/Fonts#MiSans
+ #FFF7F9FC
+ #22000000
+ #FF171A20
+ #FF657080
+ #FF2D73E5
+
+
+
+
+
+
+
+
+
+
+
+
+ 18
+ 10
+ 8
+
+
diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml.cs b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs
new file mode 100644
index 0000000..fb98789
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace LanMountainDesktop.AirAppHost;
+
+public sealed partial class AirApp : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var options = AirAppLaunchOptions.Parse(desktop.Args ?? []);
+ desktop.MainWindow = new AirAppWindow(options);
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
new file mode 100644
index 0000000..7094b9d
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
@@ -0,0 +1,79 @@
+namespace LanMountainDesktop.AirAppHost;
+
+public sealed record AirAppLaunchOptions(
+ string AppId,
+ string SessionId,
+ string? SourceComponentId,
+ string? SourcePlacementId,
+ string? LauncherPipeName,
+ string? InstanceKey,
+ string? DataRoot)
+{
+ public const string WorldClockAppId = "world-clock";
+ public const string WhiteboardAppId = "whiteboard";
+
+ public static AirAppLaunchOptions Parse(IReadOnlyList args)
+ {
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var index = 0; index < args.Count; index++)
+ {
+ var arg = args[index];
+ if (!arg.StartsWith("--", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var key = arg[2..].Trim();
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ continue;
+ }
+
+ var equalsIndex = key.IndexOf('=');
+ if (equalsIndex > 0)
+ {
+ var inlineValue = key[(equalsIndex + 1)..];
+ key = key[..equalsIndex].Trim();
+ if (!string.IsNullOrWhiteSpace(key))
+ {
+ values[key] = inlineValue;
+ }
+
+ continue;
+ }
+
+ if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ values[key] = args[index + 1];
+ index++;
+ }
+ else
+ {
+ values[key] = "true";
+ }
+ }
+
+ return new AirAppLaunchOptions(
+ GetValue(values, "app-id", WorldClockAppId),
+ GetValue(values, "session-id", Guid.NewGuid().ToString("N")),
+ GetOptionalValue(values, "source-component-id"),
+ GetOptionalValue(values, "source-placement-id"),
+ GetOptionalValue(values, "launcher-pipe"),
+ GetOptionalValue(values, "instance-key"),
+ GetOptionalValue(values, "data-root"));
+ }
+
+ private static string GetValue(IReadOnlyDictionary values, string key, string fallback)
+ {
+ return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
+ ? value.Trim()
+ : fallback;
+ }
+
+ private static string? GetOptionalValue(IReadOnlyDictionary values, string key)
+ {
+ return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
+ ? value.Trim()
+ : null;
+ }
+}
diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml
new file mode 100644
index 0000000..6cba450
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
new file mode 100644
index 0000000..6e3af58
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
@@ -0,0 +1,274 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Windowing;
+using LanMountainDesktop.ComponentSystem;
+using LanMountainDesktop.Services;
+using LanMountainDesktop.Shared.IPC;
+using LanMountainDesktop.Shared.IPC.Abstractions.Services;
+using LanMountainDesktop.Views.Components;
+
+namespace LanMountainDesktop.AirAppHost;
+
+public sealed partial class AirAppWindow : FAAppWindow
+{
+ private readonly AirAppLaunchOptions _options;
+ private readonly AirAppWindowDescriptor _descriptor;
+ private WhiteboardWidget? _whiteboardWidget;
+ private string _instanceKey = string.Empty;
+
+ public AirAppWindow()
+ : this(AirAppLaunchOptions.Parse([]))
+ {
+ }
+
+ public AirAppWindow(AirAppLaunchOptions options)
+ {
+ _options = options;
+ _descriptor = AirAppWindowDescriptor.Create(options);
+ InitializeComponent();
+ ConfigureWindow();
+ }
+
+ private void ConfigureWindow()
+ {
+ ApplyWindowDescriptor(_descriptor);
+
+ if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase))
+ {
+ ContentHost.Content = new ClockAirAppView(_options);
+ return;
+ }
+
+ if (string.Equals(_options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase))
+ {
+ ConfigureWhiteboardWindow();
+ return;
+ }
+
+ ContentHost.Content = new TextBlock
+ {
+ Text = $"Unsupported Air APP: {_options.AppId}",
+ Margin = new Avalonia.Thickness(18)
+ };
+ }
+
+ private void ApplyWindowDescriptor(AirAppWindowDescriptor descriptor)
+ {
+ Title = descriptor.Title;
+ Width = descriptor.Width;
+ Height = descriptor.Height;
+ MinWidth = descriptor.MinWidth;
+ MinHeight = descriptor.MinHeight;
+ ShowInTaskbar = descriptor.ShowInTaskbar;
+ CanResize = descriptor.CanResize;
+ ShowAsDialog = descriptor.ShowAsDialog;
+ WindowState = WindowState.Normal;
+ WindowRoot.Background = this.TryFindResource("AirAppWindowBackgroundBrush", out var brush) && brush is IBrush backgroundBrush
+ ? backgroundBrush
+ : Brushes.White;
+ ConfigureTitleBar(descriptor);
+
+ switch (descriptor.ChromeMode)
+ {
+ case AirAppWindowChromeMode.Standard:
+ WindowDecorations = WindowDecorations.Full;
+ TitleBar.ExtendsContentIntoTitleBar = false;
+ break;
+
+ case AirAppWindowChromeMode.Borderless:
+ WindowDecorations = WindowDecorations.None;
+ TitleBar.ExtendsContentIntoTitleBar = true;
+ break;
+
+ case AirAppWindowChromeMode.FullScreen:
+ WindowDecorations = WindowDecorations.None;
+ TitleBar.ExtendsContentIntoTitleBar = true;
+ ShowAsDialog = false;
+ WindowState = WindowState.FullScreen;
+ break;
+
+ case AirAppWindowChromeMode.Tool:
+ WindowDecorations = WindowDecorations.Full;
+ TitleBar.ExtendsContentIntoTitleBar = false;
+ ShowInTaskbar = false;
+ CanResize = false;
+ break;
+
+ case AirAppWindowChromeMode.BackgroundOnly:
+ // Reserved for future background-only Air APPs. Keep a normal window for now
+ // so accidental launches remain visible and debuggable.
+ break;
+ }
+ }
+
+ private void ConfigureTitleBar(AirAppWindowDescriptor descriptor)
+ {
+ TitleBar.Height = descriptor.ChromeMode == AirAppWindowChromeMode.Tool ? 36 : 40;
+ TitleBar.BackgroundColor = Colors.Transparent;
+ TitleBar.ForegroundColor = Color.FromRgb(32, 32, 32);
+ TitleBar.InactiveBackgroundColor = Colors.Transparent;
+ TitleBar.InactiveForegroundColor = Color.FromRgb(96, 96, 96);
+ TitleBar.ButtonBackgroundColor = Colors.Transparent;
+ TitleBar.ButtonHoverBackgroundColor = Color.FromArgb(23, 0, 0, 0);
+ TitleBar.ButtonPressedBackgroundColor = Color.FromArgb(52, 0, 0, 0);
+ TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
+ TitleBar.ButtonInactiveForegroundColor = Colors.Gray;
+ }
+
+ private void ConfigureWhiteboardWindow()
+ {
+ var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId)
+ ? BuiltInComponentIds.DesktopWhiteboard
+ : _options.SourceComponentId.Trim();
+ var baseWidthCells = string.Equals(componentId, BuiltInComponentIds.DesktopBlackboardLandscape, StringComparison.OrdinalIgnoreCase)
+ ? 4
+ : 2;
+ var widget = new WhiteboardWidget(baseWidthCells);
+ _whiteboardWidget = widget;
+ widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId);
+ widget.SetSurfaceMode(
+ WhiteboardWidgetSurfaceMode.AirApp,
+ () =>
+ {
+ widget.ForceSaveNote();
+ Close();
+ });
+
+ ContentHost.Content = widget;
+ AppLogger.Info(
+ "AirAppWindow",
+ $"Whiteboard content created. ComponentId='{componentId}'; PlacementId='{_options.SourcePlacementId ?? string.Empty}'.");
+ }
+
+ protected override void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+ _ = RegisterWithLauncherAsync();
+ AppLogger.Info(
+ "AirAppWindow",
+ $"Opened. WindowRole=AirApp; AppId='{_options.AppId}'; ForegroundActivationRequested=True.");
+ Dispatcher.UIThread.Post(() =>
+ {
+ Activate();
+ }, DispatcherPriority.Background);
+ }
+
+ protected override void OnClosing(WindowClosingEventArgs e)
+ {
+ SaveWhiteboard();
+ base.OnClosing(e);
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ SaveAndDisposeWhiteboard();
+ _ = UnregisterWithLauncherAsync();
+ base.OnClosed(e);
+ }
+
+ private void SaveAndDisposeWhiteboard()
+ {
+ var widget = _whiteboardWidget;
+ if (widget is null)
+ {
+ return;
+ }
+
+ SaveWhiteboard();
+ if (ContentHost.Content == widget)
+ {
+ ContentHost.Content = null;
+ }
+
+ widget.Dispose();
+ _whiteboardWidget = null;
+ }
+
+ private void SaveWhiteboard()
+ {
+ if (_whiteboardWidget is null)
+ {
+ return;
+ }
+
+ try
+ {
+ _whiteboardWidget.ForceSaveNote();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("AirAppWindow", "Failed to force-save whiteboard before closing Air APP.", ex);
+ }
+ }
+
+ private async Task RegisterWithLauncherAsync()
+ {
+ if (string.IsNullOrWhiteSpace(_options.LauncherPipeName))
+ {
+ return;
+ }
+
+ _instanceKey = ResolveInstanceKey();
+ try
+ {
+ using var client = new LanMountainDesktopIpcClient();
+ await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false);
+ var proxy = client.CreateProxy();
+ _ = await proxy.RegisterAsync(new AirAppRegistrationRequest(
+ _instanceKey,
+ _options.AppId,
+ _options.SessionId,
+ Environment.ProcessId,
+ Title ?? "Air APP",
+ _options.SourceComponentId,
+ _options.SourcePlacementId)).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Registration is best-effort; Launcher also tracks the process it started.
+ }
+ }
+
+ private async Task UnregisterWithLauncherAsync()
+ {
+ if (string.IsNullOrWhiteSpace(_options.LauncherPipeName))
+ {
+ return;
+ }
+
+ var instanceKey = string.IsNullOrWhiteSpace(_instanceKey) ? ResolveInstanceKey() : _instanceKey;
+ try
+ {
+ using var client = new LanMountainDesktopIpcClient();
+ await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false);
+ var proxy = client.CreateProxy();
+ _ = await proxy.UnregisterAsync(instanceKey, Environment.ProcessId).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Unregister is best-effort; Launcher prunes dead processes.
+ }
+ }
+
+ private string ResolveInstanceKey()
+ {
+ if (!string.IsNullOrWhiteSpace(_options.InstanceKey))
+ {
+ return _options.InstanceKey.Trim();
+ }
+
+ if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{AirAppLaunchOptions.WorldClockAppId}:clock-suite:global";
+ }
+
+ var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId)
+ ? "none"
+ : _options.SourceComponentId.Trim();
+ var placementId = string.IsNullOrWhiteSpace(_options.SourcePlacementId)
+ ? "none"
+ : _options.SourcePlacementId.Trim();
+ return $"{_options.AppId}:{componentId}:{placementId}";
+ }
+}
diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs
new file mode 100644
index 0000000..1fa8a19
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs
@@ -0,0 +1,10 @@
+namespace LanMountainDesktop.AirAppHost;
+
+public enum AirAppWindowChromeMode
+{
+ Standard,
+ Borderless,
+ FullScreen,
+ Tool,
+ BackgroundOnly
+}
diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
new file mode 100644
index 0000000..4a5111d
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
@@ -0,0 +1,151 @@
+namespace LanMountainDesktop.AirAppHost;
+
+public sealed record AirAppWindowDescriptor(
+ string WindowTitle,
+ string TitleBarTitle,
+ string TitleBarSubtitle,
+ AirAppWindowChromeMode ChromeMode,
+ bool CanResize,
+ bool ShowInTaskbar,
+ bool ShowAsDialog,
+ double Width,
+ double Height,
+ double MinWidth,
+ double MinHeight)
+{
+ public string Title => WindowTitle;
+
+ public string TitleText => TitleBarTitle;
+
+ public string SubtitleText => TitleBarSubtitle;
+
+ public static AirAppWindowDescriptor Create(AirAppLaunchOptions options)
+ {
+ if (string.Equals(options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase))
+ {
+ return Standard(
+ "Clock - Air APP",
+ "Clock",
+ "Air APP",
+ width: 780,
+ height: 560,
+ minWidth: 680,
+ minHeight: 480,
+ canResize: true,
+ showAsDialog: false);
+ }
+
+ if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase))
+ {
+ return FullScreen(
+ "Whiteboard - Air APP",
+ "Whiteboard",
+ "Air APP");
+ }
+
+ return Standard(
+ "Air APP",
+ "Air APP",
+ options.AppId);
+ }
+
+ public static AirAppWindowDescriptor Standard(
+ string windowTitle,
+ string titleBarTitle,
+ string titleBarSubtitle,
+ double width = 520,
+ double height = 360,
+ double minWidth = 360,
+ double minHeight = 260,
+ bool canResize = true,
+ bool showAsDialog = false)
+ {
+ return new AirAppWindowDescriptor(
+ windowTitle,
+ titleBarTitle,
+ titleBarSubtitle,
+ AirAppWindowChromeMode.Standard,
+ CanResize: canResize,
+ ShowInTaskbar: true,
+ ShowAsDialog: showAsDialog,
+ width,
+ height,
+ minWidth,
+ minHeight);
+ }
+
+ public static AirAppWindowDescriptor FullScreen(
+ string windowTitle,
+ string titleBarTitle,
+ string titleBarSubtitle)
+ {
+ return new AirAppWindowDescriptor(
+ windowTitle,
+ titleBarTitle,
+ titleBarSubtitle,
+ AirAppWindowChromeMode.FullScreen,
+ CanResize: false,
+ ShowInTaskbar: true,
+ ShowAsDialog: false,
+ Width: 1280,
+ Height: 720,
+ MinWidth: 360,
+ MinHeight: 260);
+ }
+
+ public static AirAppWindowDescriptor Borderless(
+ string windowTitle,
+ double width = 520,
+ double height = 360)
+ {
+ return new AirAppWindowDescriptor(
+ windowTitle,
+ string.Empty,
+ string.Empty,
+ AirAppWindowChromeMode.Borderless,
+ CanResize: true,
+ ShowInTaskbar: true,
+ ShowAsDialog: false,
+ width,
+ height,
+ MinWidth: 240,
+ MinHeight: 180);
+ }
+
+ public static AirAppWindowDescriptor Tool(
+ string windowTitle,
+ string titleBarTitle,
+ string titleBarSubtitle,
+ double width = 360,
+ double height = 260)
+ {
+ return new AirAppWindowDescriptor(
+ windowTitle,
+ titleBarTitle,
+ titleBarSubtitle,
+ AirAppWindowChromeMode.Tool,
+ CanResize: false,
+ ShowInTaskbar: false,
+ ShowAsDialog: true,
+ width,
+ height,
+ MinWidth: 240,
+ MinHeight: 180);
+ }
+
+ public static AirAppWindowDescriptor BackgroundOnly(string appId)
+ {
+ return new AirAppWindowDescriptor(
+ $"{appId} - Air APP",
+ string.Empty,
+ string.Empty,
+ AirAppWindowChromeMode.BackgroundOnly,
+ CanResize: false,
+ ShowInTaskbar: false,
+ ShowAsDialog: false,
+ Width: 1,
+ Height: 1,
+ MinWidth: 1,
+ MinHeight: 1);
+ }
+}
diff --git a/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml b/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml
new file mode 100644
index 0000000..a246b95
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml
@@ -0,0 +1,310 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml.cs b/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml.cs
new file mode 100644
index 0000000..6e22bc4
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml.cs
@@ -0,0 +1,665 @@
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using LanMountainDesktop.Services;
+using LanMountainDesktop.Services.ClockAirApp;
+
+namespace LanMountainDesktop.AirAppHost;
+
+public sealed partial class ClockAirAppView : UserControl
+{
+ private sealed class WorldClockRowVisual
+ {
+ public required TimeZoneInfo TimeZone { get; init; }
+
+ public required TextBlock TimeTextBlock { get; init; }
+
+ public required TextBlock DateTextBlock { get; init; }
+
+ public required TextBlock OffsetTextBlock { get; init; }
+ }
+
+ private readonly DispatcherTimer _clockTimer = new()
+ {
+ Interval = TimeSpan.FromMilliseconds(250)
+ };
+
+ private readonly AirAppLaunchOptions _options;
+ private readonly ClockAirAppSettingsStore _settingsStore = new();
+ private readonly LocalizationService _localizationService = new();
+ private readonly ClockAirAppStopwatchState _stopwatchState = new();
+ private readonly ClockAirAppTimerState _timerState = new();
+ private readonly List _allTimeZones;
+ private readonly List _worldClockRows = [];
+
+ private ClockAirAppSettingsSnapshot _settings = ClockAirAppSettingsSnapshot.Normalize(null);
+ private CultureInfo _culture = CultureInfo.CurrentCulture;
+ private string _languageCode = "zh-CN";
+ private string _selectedTab = ClockAirAppTabIds.WorldClock;
+ private bool _suppressSettingsEvents;
+
+ public ClockAirAppView()
+ : this(AirAppLaunchOptions.Parse([]))
+ {
+ }
+
+ public ClockAirAppView(AirAppLaunchOptions options)
+ {
+ _options = options;
+ _allTimeZones = TimeZoneInfo.GetSystemTimeZones()
+ .OrderBy(static zone => zone.GetUtcOffset(DateTime.UtcNow))
+ .ThenBy(static zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ InitializeComponent();
+ LoadLanguage();
+ LoadSettings();
+ ApplyLocalizedText();
+ PopulateSettingsControls();
+ PopulateTimeZoneCombo();
+ RebuildWorldClockRows();
+ SelectStartupTab();
+ UpdateAll();
+
+ _clockTimer.Tick += OnClockTimerTick;
+ AttachedToVisualTree += (_, _) =>
+ {
+ UpdateAll();
+ _clockTimer.Start();
+ };
+ DetachedFromVisualTree += (_, _) => _clockTimer.Stop();
+ }
+
+ private void LoadLanguage()
+ {
+ try
+ {
+ var appSettings = new AppSettingsService().Load();
+ _languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
+ _culture = CultureInfo.GetCultureInfo(_languageCode);
+ }
+ catch
+ {
+ _languageCode = "zh-CN";
+ _culture = CultureInfo.GetCultureInfo("zh-CN");
+ }
+ }
+
+ private void LoadSettings()
+ {
+ _settings = _settingsStore.Load();
+ _timerState.SetDuration(TimeSpan.FromMinutes(5));
+ }
+
+ private void ApplyLocalizedText()
+ {
+ HeaderTitleTextBlock.Text = L("clockairapp.title", "Clock");
+ HeaderSubtitleTextBlock.Text = L("clockairapp.subtitle", "World clock, stopwatch and timer");
+
+ WorldTabButton.Content = L("clockairapp.tab.world", "World");
+ StopwatchTabButton.Content = L("clockairapp.tab.stopwatch", "Stopwatch");
+ TimerTabButton.Content = L("clockairapp.tab.timer", "Timer");
+ SettingsTabButton.Content = L("clockairapp.tab.settings", "Settings");
+
+ LocalLabelTextBlock.Text = L("clockairapp.world.local", "Local time");
+ AddCityButton.Content = L("clockairapp.world.add", "Add");
+ TimeZoneSearchTextBox.PlaceholderText = L("clockairapp.world.search", "Search city or time zone");
+
+ StopwatchHintTextBlock.Text = L("clockairapp.stopwatch.hint", "Lap timing stays in this window session.");
+ StopwatchStartPauseButton.Content = L("clockairapp.action.start", "Start");
+ StopwatchLapButton.Content = L("clockairapp.stopwatch.lap", "Lap");
+ StopwatchResetButton.Content = L("clockairapp.action.reset", "Reset");
+
+ TimerHintTextBlock.Text = L("clockairapp.timer.hint", "Choose a preset or enter custom minutes.");
+ TimerApplyButton.Content = L("clockairapp.timer.apply", "Apply");
+ TimerStartPauseButton.Content = L("clockairapp.action.start", "Start");
+ TimerResetButton.Content = L("clockairapp.action.reset", "Reset");
+ TimerMinutesTextBox.PlaceholderText = L("clockairapp.timer.minutes", "Minutes");
+
+ SettingsHeaderTextBlock.Text = L("clockairapp.settings.title", "Clock settings");
+ TimeFormatLabelTextBlock.Text = L("clockairapp.settings.time_format", "Time format");
+ StartupTabLabelTextBlock.Text = L("clockairapp.settings.startup_tab", "Startup page");
+ ShowSecondsCheckBox.Content = L("clockairapp.settings.show_seconds", "Show seconds");
+ ActivateOnTimerFinishedCheckBox.Content = L("clockairapp.settings.activate_timer", "Activate window when timer finishes");
+ }
+
+ private void PopulateSettingsControls()
+ {
+ _suppressSettingsEvents = true;
+ try
+ {
+ SetComboItems(
+ TimeFormatComboBox,
+ [
+ (ClockAirAppTimeFormatMode.System, L("clockairapp.settings.time_format.system", "Follow system")),
+ (ClockAirAppTimeFormatMode.TwentyFourHour, L("clockairapp.settings.time_format.24h", "24-hour")),
+ (ClockAirAppTimeFormatMode.TwelveHour, L("clockairapp.settings.time_format.12h", "12-hour"))
+ ],
+ _settings.TimeFormatMode);
+ SetComboItems(
+ StartupTabComboBox,
+ [
+ (ClockAirAppTabIds.Last, L("clockairapp.settings.startup.last", "Last used")),
+ (ClockAirAppTabIds.WorldClock, L("clockairapp.tab.world", "World")),
+ (ClockAirAppTabIds.Stopwatch, L("clockairapp.tab.stopwatch", "Stopwatch")),
+ (ClockAirAppTabIds.Timer, L("clockairapp.tab.timer", "Timer"))
+ ],
+ _settings.StartupTab);
+ ShowSecondsCheckBox.IsChecked = _settings.ShowSeconds;
+ ActivateOnTimerFinishedCheckBox.IsChecked = _settings.ActivateOnTimerFinished;
+ }
+ finally
+ {
+ _suppressSettingsEvents = false;
+ }
+ }
+
+ private static void SetComboItems(ComboBox comboBox, IEnumerable<(string Id, string Text)> items, string selectedId)
+ {
+ comboBox.Items.Clear();
+ foreach (var item in items)
+ {
+ comboBox.Items.Add(new ComboBoxItem
+ {
+ Tag = item.Id,
+ Content = item.Text
+ });
+ }
+
+ comboBox.SelectedItem = comboBox.Items
+ .OfType()
+ .FirstOrDefault(item => string.Equals(item.Tag as string, selectedId, StringComparison.OrdinalIgnoreCase))
+ ?? comboBox.Items.OfType().FirstOrDefault();
+ }
+
+ private void SelectStartupTab()
+ {
+ var startupTab = ClockAirAppTabIds.Normalize(_settings.StartupTab, ClockAirAppTabIds.Last);
+ var tab = string.Equals(startupTab, ClockAirAppTabIds.Last, StringComparison.OrdinalIgnoreCase)
+ ? ClockAirAppTabIds.Normalize(_settings.LastSelectedTab)
+ : ClockAirAppTabIds.Normalize(startupTab);
+ SelectTab(tab, save: false);
+ }
+
+ private void OnClockTimerTick(object? sender, EventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ UpdateAll();
+ }
+
+ private void UpdateAll()
+ {
+ var now = DateTimeOffset.Now;
+ UpdateWorldClock(now);
+ UpdateStopwatch(now);
+ UpdateTimer(now);
+ }
+
+ private void UpdateWorldClock(DateTimeOffset now)
+ {
+ var localNow = now.LocalDateTime;
+ LocalTimeTextBlock.Text = ClockAirAppTimeFormatter.FormatTime(localNow, _settings, _culture);
+ LocalDateTextBlock.Text = localNow.ToString("yyyy-MM-dd dddd", _culture);
+ LocalTimeZoneTextBlock.Text = TimeZoneInfo.Local.DisplayName;
+ WorldSummaryTextBlock.Text = Lf("clockairapp.world.count", "{0} cities", _settings.WorldClockTimeZoneIds.Count);
+
+ var utcNow = now.UtcDateTime;
+ foreach (var row in _worldClockRows)
+ {
+ var zonedTime = TimeZoneInfo.ConvertTimeFromUtc(utcNow, row.TimeZone);
+ row.TimeTextBlock.Text = ClockAirAppTimeFormatter.FormatTime(zonedTime, _settings, _culture);
+ row.DateTextBlock.Text = $"{ResolveRelativeDayLabel((zonedTime.Date - localNow.Date).Days)} - {zonedTime.ToString("yyyy-MM-dd", _culture)}";
+ row.OffsetTextBlock.Text = ClockAirAppTimeFormatter.FormatUtcOffset(row.TimeZone.GetUtcOffset(utcNow));
+ }
+ }
+
+ private void UpdateStopwatch(DateTimeOffset now)
+ {
+ StopwatchElapsedTextBlock.Text = ClockAirAppTimeFormatter.FormatDuration(_stopwatchState.GetElapsed(now), includeMilliseconds: true);
+ StopwatchStartPauseButton.Content = _stopwatchState.IsRunning
+ ? L("clockairapp.action.pause", "Pause")
+ : L("clockairapp.action.start", "Start");
+ StopwatchLapButton.IsEnabled = _stopwatchState.GetElapsed(now) > TimeSpan.Zero;
+ StopwatchResetButton.IsEnabled = _stopwatchState.GetElapsed(now) > TimeSpan.Zero || _stopwatchState.Laps.Count > 0;
+ }
+
+ private void UpdateTimer(DateTimeOffset now)
+ {
+ if (_timerState.Update(now))
+ {
+ TimerStatusTextBlock.Text = L("clockairapp.timer.finished", "Timer finished");
+ if (_settings.ActivateOnTimerFinished && VisualRoot is Window window)
+ {
+ window.Activate();
+ }
+ }
+
+ TimerRemainingTextBlock.Text = ClockAirAppTimeFormatter.FormatDuration(_timerState.GetRemaining(now));
+ TimerStartPauseButton.Content = _timerState.IsRunning
+ ? L("clockairapp.action.pause", "Pause")
+ : L("clockairapp.action.start", "Start");
+ TimerResetButton.IsEnabled = _timerState.GetRemaining(now) < _timerState.Duration || _timerState.IsCompleted;
+ if (!_timerState.IsCompleted && string.IsNullOrWhiteSpace(TimerStatusTextBlock.Text))
+ {
+ TimerStatusTextBlock.Text = Lf("clockairapp.timer.duration_status", "Duration {0}", ClockAirAppTimeFormatter.FormatDuration(_timerState.Duration));
+ }
+ }
+
+ private void OnTabButtonClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleButton button && button.Tag is string tab)
+ {
+ SelectTab(tab, save: true);
+ }
+ }
+
+ private void SelectTab(string tab, bool save)
+ {
+ _selectedTab = ClockAirAppTabIds.Normalize(tab);
+ WorldPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.WorldClock, StringComparison.OrdinalIgnoreCase);
+ StopwatchPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.Stopwatch, StringComparison.OrdinalIgnoreCase);
+ TimerPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.Timer, StringComparison.OrdinalIgnoreCase);
+ SettingsPage.IsVisible = string.Equals(_selectedTab, ClockAirAppTabIds.Settings, StringComparison.OrdinalIgnoreCase);
+
+ WorldTabButton.IsChecked = WorldPage.IsVisible;
+ StopwatchTabButton.IsChecked = StopwatchPage.IsVisible;
+ TimerTabButton.IsChecked = TimerPage.IsVisible;
+ SettingsTabButton.IsChecked = SettingsPage.IsVisible;
+
+ if (save)
+ {
+ _settings.LastSelectedTab = _selectedTab;
+ _settingsStore.Save(_settings);
+ }
+ }
+
+ private void OnTimeZoneSearchChanged(object? sender, TextChangedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ PopulateTimeZoneCombo();
+ }
+
+ private void PopulateTimeZoneCombo()
+ {
+ var query = TimeZoneSearchTextBox.Text?.Trim() ?? string.Empty;
+ var zones = _allTimeZones
+ .Where(zone => MatchesTimeZoneQuery(zone, query))
+ .Take(80)
+ .ToList();
+
+ TimeZoneComboBox.Items.Clear();
+ foreach (var zone in zones)
+ {
+ TimeZoneComboBox.Items.Add(new ComboBoxItem
+ {
+ Tag = zone.Id,
+ Content = FormatTimeZoneOption(zone)
+ });
+ }
+
+ TimeZoneComboBox.SelectedItem = TimeZoneComboBox.Items.OfType().FirstOrDefault();
+ }
+
+ private bool MatchesTimeZoneQuery(TimeZoneInfo zone, string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ return true;
+ }
+
+ var cityName = ClockAirAppTimeFormatter.ResolveCityName(zone, _languageCode);
+ return zone.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ zone.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ zone.StandardName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ cityName.Contains(query, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private string FormatTimeZoneOption(TimeZoneInfo zone)
+ {
+ return $"{ClockAirAppTimeFormatter.FormatUtcOffset(zone.GetUtcOffset(DateTime.UtcNow))} | {ClockAirAppTimeFormatter.ResolveCityName(zone, _languageCode)} | {zone.StandardName}";
+ }
+
+ private void OnAddCityClick(object? sender, RoutedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ if (TimeZoneComboBox.SelectedItem is not ComboBoxItem item || item.Tag is not string zoneId)
+ {
+ return;
+ }
+
+ if (_settings.WorldClockTimeZoneIds.Any(existing => string.Equals(existing, zoneId, StringComparison.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
+ _settings.WorldClockTimeZoneIds.Add(zoneId);
+ SaveWorldClockSettings();
+ }
+
+ private void RebuildWorldClockRows()
+ {
+ _worldClockRows.Clear();
+ WorldClockRowsPanel.Children.Clear();
+ for (var index = 0; index < _settings.WorldClockTimeZoneIds.Count; index++)
+ {
+ var timeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(_settings.WorldClockTimeZoneIds[index]);
+ AddWorldClockRow(timeZone, index);
+ }
+ }
+
+ private void AddWorldClockRow(TimeZoneInfo timeZone, int index)
+ {
+ var cityText = new TextBlock
+ {
+ Text = ClockAirAppTimeFormatter.ResolveCityName(timeZone, _languageCode),
+ FontSize = 15,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = TryGetBrush("AirAppTitleTextBrush", "#FF171A20")
+ };
+ var timeText = new TextBlock
+ {
+ FontSize = 24,
+ FontWeight = FontWeight.SemiBold,
+ LetterSpacing = 0,
+ Foreground = TryGetBrush("AirAppTitleTextBrush", "#FF171A20"),
+ HorizontalAlignment = HorizontalAlignment.Right
+ };
+ var dateText = new TextBlock
+ {
+ FontSize = 12,
+ Foreground = TryGetBrush("AirAppSecondaryTextBrush", "#FF657080")
+ };
+ var offsetText = new TextBlock
+ {
+ FontSize = 12,
+ Foreground = TryGetBrush("AirAppSecondaryTextBrush", "#FF657080"),
+ HorizontalAlignment = HorizontalAlignment.Right
+ };
+
+ var upButton = CreateIconButton("↑", L("clockairapp.action.move_up", "Move up"));
+ upButton.IsEnabled = index > 0;
+ upButton.Click += (_, _) => MoveWorldClock(index, -1);
+
+ var downButton = CreateIconButton("↓", L("clockairapp.action.move_down", "Move down"));
+ downButton.IsEnabled = index < _settings.WorldClockTimeZoneIds.Count - 1;
+ downButton.Click += (_, _) => MoveWorldClock(index, 1);
+
+ var removeButton = CreateIconButton("×", L("clockairapp.action.remove", "Remove"));
+ removeButton.IsEnabled = _settings.WorldClockTimeZoneIds.Count > 1;
+ removeButton.Click += (_, _) => RemoveWorldClock(index);
+
+ var row = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto,Auto"),
+ ColumnSpacing = 8
+ };
+ var leftStack = new StackPanel
+ {
+ Spacing = 3,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children =
+ {
+ cityText,
+ dateText
+ }
+ };
+ var timeStack = new StackPanel
+ {
+ Spacing = 2,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children =
+ {
+ timeText,
+ offsetText
+ }
+ };
+ row.Children.Add(leftStack);
+ row.Children.Add(timeStack);
+ row.Children.Add(upButton);
+ row.Children.Add(downButton);
+ row.Children.Add(removeButton);
+ Grid.SetColumn(timeStack, 1);
+ Grid.SetColumn(upButton, 2);
+ Grid.SetColumn(downButton, 3);
+ Grid.SetColumn(removeButton, 4);
+
+ WorldClockRowsPanel.Children.Add(new Border
+ {
+ Background = new SolidColorBrush(Color.Parse("#0A000000")),
+ CornerRadius = new CornerRadius(14),
+ Padding = new Thickness(12, 10),
+ Child = row
+ });
+
+ _worldClockRows.Add(new WorldClockRowVisual
+ {
+ TimeZone = timeZone,
+ TimeTextBlock = timeText,
+ DateTextBlock = dateText,
+ OffsetTextBlock = offsetText
+ });
+ }
+
+ private Button CreateIconButton(string text, string tooltip)
+ {
+ var button = new Button
+ {
+ Content = text,
+ Classes = { "clock-icon-command" }
+ };
+ ToolTip.SetTip(button, tooltip);
+ return button;
+ }
+
+ private void MoveWorldClock(int index, int delta)
+ {
+ var nextIndex = index + delta;
+ if (index < 0 || nextIndex < 0 || index >= _settings.WorldClockTimeZoneIds.Count || nextIndex >= _settings.WorldClockTimeZoneIds.Count)
+ {
+ return;
+ }
+
+ (_settings.WorldClockTimeZoneIds[index], _settings.WorldClockTimeZoneIds[nextIndex]) =
+ (_settings.WorldClockTimeZoneIds[nextIndex], _settings.WorldClockTimeZoneIds[index]);
+ SaveWorldClockSettings();
+ }
+
+ private void RemoveWorldClock(int index)
+ {
+ if (_settings.WorldClockTimeZoneIds.Count <= 1 || index < 0 || index >= _settings.WorldClockTimeZoneIds.Count)
+ {
+ return;
+ }
+
+ _settings.WorldClockTimeZoneIds.RemoveAt(index);
+ SaveWorldClockSettings();
+ }
+
+ private void SaveWorldClockSettings()
+ {
+ _settings = ClockAirAppSettingsSnapshot.Normalize(_settings);
+ _settingsStore.Save(_settings);
+ RebuildWorldClockRows();
+ UpdateWorldClock(DateTimeOffset.Now);
+ }
+
+ private void OnStopwatchStartPauseClick(object? sender, RoutedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ var now = DateTimeOffset.Now;
+ if (_stopwatchState.IsRunning)
+ {
+ _stopwatchState.Pause(now);
+ }
+ else
+ {
+ _stopwatchState.StartOrResume(now);
+ }
+
+ UpdateStopwatch(now);
+ }
+
+ private void OnStopwatchLapClick(object? sender, RoutedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ _ = _stopwatchState.AddLap(DateTimeOffset.Now);
+ RebuildStopwatchLaps();
+ }
+
+ private void OnStopwatchResetClick(object? sender, RoutedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ _stopwatchState.Reset();
+ RebuildStopwatchLaps();
+ UpdateStopwatch(DateTimeOffset.Now);
+ }
+
+ private void RebuildStopwatchLaps()
+ {
+ StopwatchLapsPanel.Children.Clear();
+ for (var index = 0; index < _stopwatchState.Laps.Count; index++)
+ {
+ var lap = _stopwatchState.Laps[index];
+ StopwatchLapsPanel.Children.Add(new TextBlock
+ {
+ Text = Lf("clockairapp.stopwatch.lap_format", "Lap {0} {1}", _stopwatchState.Laps.Count - index, ClockAirAppTimeFormatter.FormatDuration(lap, includeMilliseconds: true)),
+ Foreground = TryGetBrush("AirAppSecondaryTextBrush", "#FF657080"),
+ FontSize = 13
+ });
+ }
+ }
+
+ private void OnTimerPresetClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button button &&
+ button.Tag is string minutesText &&
+ int.TryParse(minutesText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var minutes))
+ {
+ SetTimerDuration(minutes);
+ }
+ }
+
+ private void OnTimerApplyClick(object? sender, RoutedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ if (!int.TryParse(TimerMinutesTextBox.Text, NumberStyles.Integer, CultureInfo.CurrentCulture, out var minutes))
+ {
+ TimerStatusTextBlock.Text = L("clockairapp.timer.invalid", "Enter a valid minute value.");
+ return;
+ }
+
+ SetTimerDuration(minutes);
+ }
+
+ private void SetTimerDuration(int minutes)
+ {
+ minutes = Math.Clamp(minutes, 1, 24 * 60);
+ TimerMinutesTextBox.Text = minutes.ToString(CultureInfo.CurrentCulture);
+ _timerState.SetDuration(TimeSpan.FromMinutes(minutes));
+ TimerStatusTextBlock.Text = Lf("clockairapp.timer.duration_status", "Duration {0}", ClockAirAppTimeFormatter.FormatDuration(_timerState.Duration));
+ UpdateTimer(DateTimeOffset.Now);
+ }
+
+ private void OnTimerStartPauseClick(object? sender, RoutedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ var now = DateTimeOffset.Now;
+ if (_timerState.IsRunning)
+ {
+ _timerState.Pause(now);
+ }
+ else
+ {
+ _timerState.StartOrResume(now);
+ TimerStatusTextBlock.Text = string.Empty;
+ }
+
+ UpdateTimer(now);
+ }
+
+ private void OnTimerResetClick(object? sender, RoutedEventArgs e)
+ {
+ _ = sender;
+ _ = e;
+ _timerState.Reset();
+ TimerStatusTextBlock.Text = Lf("clockairapp.timer.duration_status", "Duration {0}", ClockAirAppTimeFormatter.FormatDuration(_timerState.Duration));
+ UpdateTimer(DateTimeOffset.Now);
+ }
+
+ private void OnSettingsChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ _ = e;
+ SaveSettingsFromControls(sender);
+ }
+
+ private void OnSettingsChanged(object? sender, RoutedEventArgs e)
+ {
+ _ = e;
+ SaveSettingsFromControls(sender);
+ }
+
+ private void SaveSettingsFromControls(object? sender)
+ {
+ _ = sender;
+ if (_suppressSettingsEvents)
+ {
+ return;
+ }
+
+ _settings.TimeFormatMode = TimeFormatComboBox.SelectedItem is ComboBoxItem timeFormatItem && timeFormatItem.Tag is string timeFormat
+ ? timeFormat
+ : ClockAirAppTimeFormatMode.System;
+ _settings.StartupTab = StartupTabComboBox.SelectedItem is ComboBoxItem startupItem && startupItem.Tag is string startupTab
+ ? startupTab
+ : ClockAirAppTabIds.Last;
+ _settings.ShowSeconds = ShowSecondsCheckBox.IsChecked == true;
+ _settings.ActivateOnTimerFinished = ActivateOnTimerFinishedCheckBox.IsChecked == true;
+ _settingsStore.Save(_settings);
+ UpdateAll();
+ }
+
+ private string ResolveRelativeDayLabel(int dayDelta)
+ {
+ if (dayDelta < 0)
+ {
+ return L("worldclock.widget.yesterday", "Yesterday");
+ }
+
+ if (dayDelta > 0)
+ {
+ return L("worldclock.widget.tomorrow", "Tomorrow");
+ }
+
+ return L("worldclock.widget.today", "Today");
+ }
+
+ private IBrush TryGetBrush(string resourceKey, string fallbackColor)
+ {
+ return this.TryFindResource(resourceKey, out var value) && value is IBrush brush
+ ? brush
+ : new SolidColorBrush(Color.Parse(fallbackColor));
+ }
+
+ private string L(string key, string fallback)
+ {
+ return _localizationService.GetString(_languageCode, key, fallback);
+ }
+
+ private string Lf(string key, string fallback, params object[] args)
+ {
+ return string.Format(_culture, L(key, fallback), args);
+ }
+}
diff --git a/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj b/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj
new file mode 100644
index 0000000..14a2984
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj
@@ -0,0 +1,33 @@
+
+
+ WinExe
+ net10.0
+ LatestMajor
+ enable
+ enable
+ true
+ ..\LanMountainDesktop\Assets\logo_nightly.ico
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.AirAppHost/Program.cs b/LanMountainDesktop.AirAppHost/Program.cs
new file mode 100644
index 0000000..4c2757e
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/Program.cs
@@ -0,0 +1,53 @@
+using Avalonia;
+using LanMountainDesktop.Services;
+
+namespace LanMountainDesktop.AirAppHost;
+
+internal static class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ AppLogger.Initialize();
+ AppDataPathProvider.Initialize(args);
+ RegisterGlobalExceptionLogging();
+ AppLogger.Info("AirAppHost", $"Starting. Args='{string.Join(" ", args)}'.");
+
+ try
+ {
+ BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+ AppLogger.Info("AirAppHost", "Exited normally.");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Critical("AirAppHost", "Unhandled startup exception.", ex);
+ throw;
+ }
+ }
+
+ private static AppBuilder BuildAvaloniaApp()
+ {
+ return AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+ }
+
+ private static void RegisterGlobalExceptionLogging()
+ {
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ {
+ AppLogger.Critical(
+ "AirAppHost",
+ "Unhandled AppDomain exception.",
+ e.ExceptionObject as Exception);
+ };
+
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ AppLogger.Error("AirAppHost", "Unobserved task exception.", e.Exception);
+ e.SetObserved();
+ };
+ }
+}
diff --git a/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
new file mode 100644
index 0000000..f9d27f2
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs
new file mode 100644
index 0000000..d9f8df1
--- /dev/null
+++ b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs
@@ -0,0 +1,52 @@
+using System.Globalization;
+using Avalonia.Controls;
+using Avalonia.Threading;
+
+namespace LanMountainDesktop.AirAppHost;
+
+public sealed partial class WorldClockAirAppView : UserControl
+{
+ private readonly DispatcherTimer _timer = new()
+ {
+ Interval = TimeSpan.FromSeconds(1)
+ };
+
+ private readonly AirAppLaunchOptions _options;
+
+ public WorldClockAirAppView()
+ : this(AirAppLaunchOptions.Parse([]))
+ {
+ }
+
+ public WorldClockAirAppView(AirAppLaunchOptions options)
+ {
+ _options = options;
+ InitializeComponent();
+
+ SessionTextBlock.Text = string.IsNullOrWhiteSpace(_options.SourcePlacementId)
+ ? "World Clock"
+ : $"World Clock / {_options.SourcePlacementId}";
+
+ _timer.Tick += OnTimerTick;
+ AttachedToVisualTree += (_, _) =>
+ {
+ UpdateTime();
+ _timer.Start();
+ };
+ DetachedFromVisualTree += (_, _) => _timer.Stop();
+ UpdateTime();
+ }
+
+ private void OnTimerTick(object? sender, EventArgs e)
+ {
+ UpdateTime();
+ }
+
+ private void UpdateTime()
+ {
+ var now = DateTime.Now;
+ TimeTextBlock.Text = now.ToString("HH:mm:ss", CultureInfo.CurrentCulture);
+ DateTextBlock.Text = now.ToString("yyyy-MM-dd dddd", CultureInfo.CurrentCulture);
+ TimeZoneTextBlock.Text = TimeZoneInfo.Local.DisplayName;
+ }
+}
diff --git a/LanMountainDesktop.Launcher/App.axaml b/LanMountainDesktop.Launcher/App.axaml
index 3fc30e9..7b0db10 100644
--- a/LanMountainDesktop.Launcher/App.axaml
+++ b/LanMountainDesktop.Launcher/App.axaml
@@ -5,5 +5,8 @@
RequestedThemeVariant="Default">
+
diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs
index 8405ee3..e19a3a4 100644
--- a/LanMountainDesktop.Launcher/App.axaml.cs
+++ b/LanMountainDesktop.Launcher/App.axaml.cs
@@ -5,7 +5,9 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
+using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
+using LanMountainDesktop.Launcher.Services.AirApp;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -61,6 +63,13 @@ public partial class App : Application
return;
}
+ if (context.IsAirAppBrokerCommand)
+ {
+ _ = RunAirAppBrokerAsync(desktop, context);
+ base.OnFrameworkInitializationCompleted();
+ return;
+ }
+
// 调试模式:只显示 DevDebugWindow,不走正常启动流程
// 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI
if (context.IsDebugMode && !context.IsPreviewCommand &&
@@ -90,6 +99,47 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
+ private static async Task RunAirAppBrokerAsync(
+ IClassicDesktopStyleApplicationLifetime desktop,
+ CommandContext context)
+ {
+ var appRoot = Commands.ResolveAppRoot(context);
+ var requesterPid = context.GetIntOption("requester-pid", 0);
+ var dataLocationResolver = new DataLocationResolver(appRoot);
+ Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
+
+ using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
+ new LauncherAirAppLifecycleService(
+ new AirAppProcessStarter(
+ new AirAppHostLocator(),
+ () => appRoot,
+ () => null,
+ () => dataLocationResolver.ResolveDataRoot())));
+ airAppIpcHost.Start();
+
+ await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
+
+ Logger.Info("Air APP broker exiting.");
+ await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
+ }
+
+ internal static async Task WaitForAirAppBrokerExitAsync(
+ int requesterPid,
+ LauncherAirAppLifecycleService airAppLifecycleService)
+ {
+ while (ShouldKeepAirAppBrokerAlive(requesterPid, airAppLifecycleService))
+ {
+ await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
+ }
+ }
+
+ internal static bool ShouldKeepAirAppBrokerAlive(
+ int requesterPid,
+ LauncherAirAppLifecycleService airAppLifecycleService)
+ {
+ return TryGetLiveProcess(requesterPid) || airAppLifecycleService.HasLiveAirApps();
+ }
+
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
switch (context.Command.ToLowerInvariant())
@@ -107,11 +157,20 @@ public partial class App : Application
{
Logger.Info("Preview command: error.");
var errorWindow = new ErrorWindow();
- errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
+ errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage);
errorWindow.Show();
_ = 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.");
@@ -165,7 +224,7 @@ public partial class App : Application
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
- var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" };
+ var messages = new[] { Strings.Preview_SplashInitializing, Strings.Preview_SplashCheckingUpdates, Strings.Preview_SplashCheckingPlugins, Strings.Preview_SplashLaunchingHost, Strings.Preview_SplashReady };
var reporter = (ISplashStageReporter)window;
for (var i = 0; i < stages.Length; i++)
@@ -184,7 +243,7 @@ public partial class App : Application
for (var i = 0; i < stages.Length; i++)
{
- window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
+ window.Report(stages[i], string.Format(Strings.Preview_UpdateProcessing, stages[i]), (i + 1) * 20);
await Task.Delay(600).ConfigureAwait(false);
}
@@ -224,10 +283,10 @@ public partial class App : Application
LauncherResult result;
SplashWindow? currentSplashWindow = splashWindow;
var appRoot = Commands.ResolveAppRoot(context);
+ var dataLocationResolver = new DataLocationResolver(appRoot);
var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
-
if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource,
successPolicy,
@@ -248,6 +307,15 @@ public partial class App : Application
return;
}
+ using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
+ new LauncherAirAppLifecycleService(
+ new AirAppProcessStarter(
+ new AirAppHostLocator(),
+ () => appRoot,
+ () => null,
+ () => dataLocationResolver.ResolveDataRoot())));
+ airAppIpcHost.Start();
+
using var coordinatorServer = new LauncherCoordinatorIpcServer(
coordinatorPipeName,
BuildCoordinatorStatusFromAttempt(reservedAttempt),
@@ -325,16 +393,52 @@ public partial class App : Application
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
+ if (result.Success)
+ {
+ var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
+ await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
+ }
+
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
+ private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
+ {
+ if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
+ int.TryParse(hostPidText, out var hostPid))
+ {
+ return hostPid;
+ }
+
+ if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
+ int.TryParse(existingHostPidText, out var existingHostPid))
+ {
+ return existingHostPid;
+ }
+
+ return fallbackHostPid;
+ }
+
+ private static async Task WaitForManagedProcessesToExitAsync(
+ int hostPid,
+ LauncherAirAppLifecycleService airAppLifecycleService)
+ {
+ Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
+ while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
+ {
+ await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
+ }
+
+ Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
+ }
+
private static async Task AttachToExistingCoordinatorAsync(
CommandContext context,
SplashWindow? splashWindow,
StartupAttemptRecord? activeCoordinatorAttempt)
{
var reporter = splashWindow as ISplashStageReporter;
- reporter?.Report("activation", "Connecting to the active launcher...");
+ reporter?.Report("activation", Strings.Preview_ActivationConnecting);
if (activeCoordinatorAttempt is not null &&
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
@@ -691,7 +795,7 @@ public partial class App : Application
try
{
- await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
+ await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
@@ -701,7 +805,7 @@ public partial class App : Application
if (success)
{
- await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
+ await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60));
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
@@ -712,7 +816,7 @@ public partial class App : Application
if (success)
{
- await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
+ await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs
index 5de2b5b..ff2105f 100644
--- a/LanMountainDesktop.Launcher/AppJsonContext.cs
+++ b/LanMountainDesktop.Launcher/AppJsonContext.cs
@@ -19,6 +19,7 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PlondsFileEntry))]
[JsonSerializable(typeof(PlondsHashDescriptor))]
[JsonSerializable(typeof(SnapshotMetadata))]
+[JsonSerializable(typeof(InstallCheckpoint))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs
index fcad276..2203bb1 100644
--- a/LanMountainDesktop.Launcher/CommandContext.cs
+++ b/LanMountainDesktop.Launcher/CommandContext.cs
@@ -4,11 +4,14 @@ namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
+ public const string AirAppBrokerCommand = "air-app-broker";
+
private const string LaunchSourceOptionName = "launch-source";
private static readonly string[] GuiCommands =
[
"launch",
+ AirAppBrokerCommand,
"apply-update",
"preview-splash",
"preview-error",
@@ -60,6 +63,9 @@ internal sealed class CommandContext
public bool IsPreviewCommand =>
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
+ public bool IsAirAppBrokerCommand =>
+ string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
+
public bool IsGuiCommand =>
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props
index d714c3d..e002f31 100644
--- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props
+++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props
@@ -44,6 +44,8 @@
+
+
diff --git a/LanMountainDesktop.Launcher/Models/UpdateModels.cs b/LanMountainDesktop.Launcher/Models/UpdateModels.cs
index e10c25b..264d912 100644
--- a/LanMountainDesktop.Launcher/Models/UpdateModels.cs
+++ b/LanMountainDesktop.Launcher/Models/UpdateModels.cs
@@ -41,6 +41,25 @@ internal sealed class SnapshotMetadata
public string Status { get; set; } = "pending";
}
+internal sealed class InstallCheckpoint
+{
+ public string SnapshotId { get; set; } = string.Empty;
+
+ public string SourceVersion { get; set; } = string.Empty;
+
+ public string? TargetVersion { get; set; }
+
+ public string? SourceDirectory { get; set; }
+
+ public string TargetDirectory { get; set; } = string.Empty;
+
+ public bool IsInitialDeployment { get; set; }
+
+ public int AppliedCount { get; set; }
+
+ public int VerifiedCount { get; set; }
+}
+
internal sealed class UpdateApplyResult
{
public bool Success { get; init; }
diff --git a/LanMountainDesktop.Launcher/Program.cs b/LanMountainDesktop.Launcher/Program.cs
index 6eeb837..9c27e83 100644
--- a/LanMountainDesktop.Launcher/Program.cs
+++ b/LanMountainDesktop.Launcher/Program.cs
@@ -34,6 +34,11 @@ public static class Program
}
LauncherRuntimeContext.Current = commandContext;
+
+ var appRoot = Commands.ResolveAppRoot(commandContext);
+ var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot);
+ LanguagePreferenceService.ApplyLanguage(languageCode);
+
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
diff --git a/LanMountainDesktop.Launcher/Resources/Strings.cs b/LanMountainDesktop.Launcher/Resources/Strings.cs
new file mode 100644
index 0000000..d52e78d
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Resources/Strings.cs
@@ -0,0 +1,207 @@
+using System.Globalization;
+using System.Resources;
+using System.Threading;
+
+namespace LanMountainDesktop.Launcher.Resources;
+
+public static class Strings
+{
+ private static readonly ResourceManager ResourceManager =
+ new("LanMountainDesktop.Launcher.Resources.Strings", typeof(Strings).Assembly);
+
+ public static string Splash_Title => ResourceManager.GetString(nameof(Splash_Title), Culture)!;
+ public static string Splash_AppName => ResourceManager.GetString(nameof(Splash_AppName), Culture)!;
+ public static string Splash_StatusInitializing => ResourceManager.GetString(nameof(Splash_StatusInitializing), Culture)!;
+ public static string Splash_DebugPreview => ResourceManager.GetString(nameof(Splash_DebugPreview), Culture)!;
+ public static string Error_Title => ResourceManager.GetString(nameof(Error_Title), Culture)!;
+ public static string Error_TitleCannotConfirm => ResourceManager.GetString(nameof(Error_TitleCannotConfirm), Culture)!;
+ public static string Error_MessageNotReached => ResourceManager.GetString(nameof(Error_MessageNotReached), Culture)!;
+ public static string Error_SuggestionTitle => ResourceManager.GetString(nameof(Error_SuggestionTitle), Culture)!;
+ public static string Error_SuggestionMessage => ResourceManager.GetString(nameof(Error_SuggestionMessage), Culture)!;
+ public static string Error_DiagnosticHeader => ResourceManager.GetString(nameof(Error_DiagnosticHeader), Culture)!;
+ public static string Error_ButtonOpenLogs => ResourceManager.GetString(nameof(Error_ButtonOpenLogs), Culture)!;
+ public static string Error_ButtonCopy => ResourceManager.GetString(nameof(Error_ButtonCopy), Culture)!;
+ public static string Error_ButtonWait => ResourceManager.GetString(nameof(Error_ButtonWait), Culture)!;
+ public static string Error_ButtonExit => ResourceManager.GetString(nameof(Error_ButtonExit), Culture)!;
+ public static string Error_ButtonRetry => ResourceManager.GetString(nameof(Error_ButtonRetry), Culture)!;
+ public static string Error_ButtonActivate => ResourceManager.GetString(nameof(Error_ButtonActivate), Culture)!;
+ public static string Error_DebugTitle => ResourceManager.GetString(nameof(Error_DebugTitle), Culture)!;
+ public static string Error_HostNotFoundTitle => ResourceManager.GetString(nameof(Error_HostNotFoundTitle), Culture)!;
+ public static string Error_HostNotFoundMessage => ResourceManager.GetString(nameof(Error_HostNotFoundMessage), Culture)!;
+ public static string Error_GenericRetryMessage => ResourceManager.GetString(nameof(Error_GenericRetryMessage), Culture)!;
+ public static string Error_GenericNoRetryMessage => ResourceManager.GetString(nameof(Error_GenericNoRetryMessage), Culture)!;
+ public static string Error_PendingTitle => ResourceManager.GetString(nameof(Error_PendingTitle), Culture)!;
+ public static string Error_PendingMessage => ResourceManager.GetString(nameof(Error_PendingMessage), Culture)!;
+ public static string Error_PendingMessageWithPid => ResourceManager.GetString(nameof(Error_PendingMessageWithPid), Culture)!;
+ public static string MultiInstance_Title => ResourceManager.GetString(nameof(MultiInstance_Title), Culture)!;
+ public static string MultiInstance_AlreadyRunning => ResourceManager.GetString(nameof(MultiInstance_AlreadyRunning), Culture)!;
+ public static string MultiInstance_AlreadyRunningMessage => ResourceManager.GetString(nameof(MultiInstance_AlreadyRunningMessage), Culture)!;
+ public static string MultiInstance_RepeatedLaunchTitle => ResourceManager.GetString(nameof(MultiInstance_RepeatedLaunchTitle), Culture)!;
+ public static string MultiInstance_RepeatedLaunchMessage => ResourceManager.GetString(nameof(MultiInstance_RepeatedLaunchMessage), Culture)!;
+ public static string MultiInstance_NoSecondProcess => ResourceManager.GetString(nameof(MultiInstance_NoSecondProcess), Culture)!;
+ public static string MultiInstance_ButtonCopy => ResourceManager.GetString(nameof(MultiInstance_ButtonCopy), Culture)!;
+ public static string MultiInstance_ButtonClose => ResourceManager.GetString(nameof(MultiInstance_ButtonClose), Culture)!;
+ public static string MultiInstance_ButtonOpenDesktop => ResourceManager.GetString(nameof(MultiInstance_ButtonOpenDesktop), Culture)!;
+ public static string MultiInstance_DetailsFormat => ResourceManager.GetString(nameof(MultiInstance_DetailsFormat), Culture)!;
+ public static string DataLocation_Title => ResourceManager.GetString(nameof(DataLocation_Title), Culture)!;
+ public static string DataLocation_ChooseLocation => ResourceManager.GetString(nameof(DataLocation_ChooseLocation), Culture)!;
+ public static string DataLocation_ChooseLocationDesc => ResourceManager.GetString(nameof(DataLocation_ChooseLocationDesc), Culture)!;
+ public static string DataLocation_NotWritable => ResourceManager.GetString(nameof(DataLocation_NotWritable), Culture)!;
+ public static string DataLocation_NotWritableDesc => ResourceManager.GetString(nameof(DataLocation_NotWritableDesc), Culture)!;
+ public static string DataLocation_SystemProfile => ResourceManager.GetString(nameof(DataLocation_SystemProfile), Culture)!;
+ public static string DataLocation_SystemProfileDesc => ResourceManager.GetString(nameof(DataLocation_SystemProfileDesc), Culture)!;
+ public static string DataLocation_Portable => ResourceManager.GetString(nameof(DataLocation_Portable), Culture)!;
+ public static string DataLocation_PortableDesc => ResourceManager.GetString(nameof(DataLocation_PortableDesc), Culture)!;
+ public static string DataLocation_ButtonCancel => ResourceManager.GetString(nameof(DataLocation_ButtonCancel), Culture)!;
+ public static string DataLocation_ButtonConfirm => ResourceManager.GetString(nameof(DataLocation_ButtonConfirm), Culture)!;
+ public static string DataLocation_MigrateWarning => ResourceManager.GetString(nameof(DataLocation_MigrateWarning), Culture)!;
+ public static string Loading_Title => ResourceManager.GetString(nameof(Loading_Title), Culture)!;
+ public static string Loading_StartingDesktop => ResourceManager.GetString(nameof(Loading_StartingDesktop), Culture)!;
+ public static string Loading_StatusInitializing => ResourceManager.GetString(nameof(Loading_StatusInitializing), Culture)!;
+ public static string Loading_StatusPreparing => ResourceManager.GetString(nameof(Loading_StatusPreparing), Culture)!;
+ public static string Loading_LoadingItems => ResourceManager.GetString(nameof(Loading_LoadingItems), Culture)!;
+ public static string Loading_Done => ResourceManager.GetString(nameof(Loading_Done), Culture)!;
+ public static string Loading_ErrorOccurred => ResourceManager.GetString(nameof(Loading_ErrorOccurred), Culture)!;
+ public static string Loading_ButtonDetails => ResourceManager.GetString(nameof(Loading_ButtonDetails), Culture)!;
+ public static string Loading_ButtonCancel => ResourceManager.GetString(nameof(Loading_ButtonCancel), Culture)!;
+ public static string Loading_StageReady => ResourceManager.GetString(nameof(Loading_StageReady), Culture)!;
+ public static string Loading_ItemPlugin => ResourceManager.GetString(nameof(Loading_ItemPlugin), Culture)!;
+ public static string Loading_ItemComponent => ResourceManager.GetString(nameof(Loading_ItemComponent), Culture)!;
+ public static string Loading_ItemResource => ResourceManager.GetString(nameof(Loading_ItemResource), Culture)!;
+ public static string Loading_ItemData => ResourceManager.GetString(nameof(Loading_ItemData), Culture)!;
+ public static string Loading_ItemDownload => ResourceManager.GetString(nameof(Loading_ItemDownload), Culture)!;
+ public static string Loading_ItemProcess => ResourceManager.GetString(nameof(Loading_ItemProcess), Culture)!;
+ public static string Loading_ItemComplete => ResourceManager.GetString(nameof(Loading_ItemComplete), Culture)!;
+ public static string Loading_TypePlugin => ResourceManager.GetString(nameof(Loading_TypePlugin), Culture)!;
+ public static string Loading_TypeComponent => ResourceManager.GetString(nameof(Loading_TypeComponent), Culture)!;
+ public static string Loading_TypeResource => ResourceManager.GetString(nameof(Loading_TypeResource), Culture)!;
+ public static string Loading_TypeData => ResourceManager.GetString(nameof(Loading_TypeData), Culture)!;
+ public static string Loading_TypeNetwork => ResourceManager.GetString(nameof(Loading_TypeNetwork), Culture)!;
+ public static string Loading_TypeSettings => ResourceManager.GetString(nameof(Loading_TypeSettings), Culture)!;
+ public static string Loading_TypeSystem => ResourceManager.GetString(nameof(Loading_TypeSystem), Culture)!;
+ public static string Loading_TypeOther => ResourceManager.GetString(nameof(Loading_TypeOther), Culture)!;
+ public static string Update_Title => ResourceManager.GetString(nameof(Update_Title), Culture)!;
+ public static string Update_AppName => ResourceManager.GetString(nameof(Update_AppName), Culture)!;
+ public static string Update_StatusUpdate => ResourceManager.GetString(nameof(Update_StatusUpdate), Culture)!;
+ public static string Update_StatusUpdating => ResourceManager.GetString(nameof(Update_StatusUpdating), Culture)!;
+ public static string Update_Complete => ResourceManager.GetString(nameof(Update_Complete), Culture)!;
+ public static string Update_Failed => ResourceManager.GetString(nameof(Update_Failed), Culture)!;
+ public static string Update_FailedMessage => ResourceManager.GetString(nameof(Update_FailedMessage), Culture)!;
+ public static string Update_DebugTitle => ResourceManager.GetString(nameof(Update_DebugTitle), Culture)!;
+ public static string Update_DebugMessage => ResourceManager.GetString(nameof(Update_DebugMessage), Culture)!;
+ public static string Update_Verifying => ResourceManager.GetString(nameof(Update_Verifying), Culture)!;
+ public static string Update_ApplyingPlugins => ResourceManager.GetString(nameof(Update_ApplyingPlugins), Culture)!;
+ public static string Update_CleaningUp => ResourceManager.GetString(nameof(Update_CleaningUp), Culture)!;
+ public static string DebugDebug_Title => ResourceManager.GetString(nameof(DebugDebug_Title), Culture)!;
+ public static string DebugDebug_SettingsTitle => ResourceManager.GetString(nameof(DebugDebug_SettingsTitle), Culture)!;
+ public static string DebugDebug_DevMode => ResourceManager.GetString(nameof(DebugDebug_DevMode), Culture)!;
+ public static string DebugDebug_DevModeDesc => ResourceManager.GetString(nameof(DebugDebug_DevModeDesc), Culture)!;
+ public static string DebugDebug_On => ResourceManager.GetString(nameof(DebugDebug_On), Culture)!;
+ public static string DebugDebug_Off => ResourceManager.GetString(nameof(DebugDebug_Off), Culture)!;
+ public static string DebugDebug_AppPath => ResourceManager.GetString(nameof(DebugDebug_AppPath), Culture)!;
+ public static string DebugDebug_NotSelected => ResourceManager.GetString(nameof(DebugDebug_NotSelected), Culture)!;
+ public static string DebugDebug_Browse => ResourceManager.GetString(nameof(DebugDebug_Browse), Culture)!;
+ public static string DebugDebug_Warning => ResourceManager.GetString(nameof(DebugDebug_Warning), Culture)!;
+ public static string DebugDebug_ButtonCancel => ResourceManager.GetString(nameof(DebugDebug_ButtonCancel), Culture)!;
+ public static string DebugDebug_ButtonOk => ResourceManager.GetString(nameof(DebugDebug_ButtonOk), Culture)!;
+ public static string DebugDebug_SelectExeDialog => ResourceManager.GetString(nameof(DebugDebug_SelectExeDialog), Culture)!;
+ public static string Oobe_Title => ResourceManager.GetString(nameof(Oobe_Title), Culture)!;
+ public static string Oobe_WelcomeTitle => ResourceManager.GetString(nameof(Oobe_WelcomeTitle), Culture)!;
+ public static string Oobe_WelcomeSubtitle => ResourceManager.GetString(nameof(Oobe_WelcomeSubtitle), Culture)!;
+ public static string Oobe_ButtonGetStarted => ResourceManager.GetString(nameof(Oobe_ButtonGetStarted), Culture)!;
+ public static string Oobe_AppearanceTitle => ResourceManager.GetString(nameof(Oobe_AppearanceTitle), Culture)!;
+ public static string Oobe_AppearanceDesc => ResourceManager.GetString(nameof(Oobe_AppearanceDesc), Culture)!;
+ public static string Oobe_AppearanceMode => ResourceManager.GetString(nameof(Oobe_AppearanceMode), Culture)!;
+ public static string Oobe_LightMode => ResourceManager.GetString(nameof(Oobe_LightMode), Culture)!;
+ public static string Oobe_DarkMode => ResourceManager.GetString(nameof(Oobe_DarkMode), Culture)!;
+ public static string Oobe_ThemeColor => ResourceManager.GetString(nameof(Oobe_ThemeColor), Culture)!;
+ public static string Oobe_MonetSource => ResourceManager.GetString(nameof(Oobe_MonetSource), Culture)!;
+ public static string Oobe_MonetFromWallpaper => ResourceManager.GetString(nameof(Oobe_MonetFromWallpaper), Culture)!;
+ public static string Oobe_MonetFromCustomImage => ResourceManager.GetString(nameof(Oobe_MonetFromCustomImage), Culture)!;
+ public static string Oobe_MonetDisabled => ResourceManager.GetString(nameof(Oobe_MonetDisabled), Culture)!;
+ public static string Oobe_DataLocationTitle => ResourceManager.GetString(nameof(Oobe_DataLocationTitle), Culture)!;
+ public static string Oobe_SystemProfile => ResourceManager.GetString(nameof(Oobe_SystemProfile), Culture)!;
+ public static string Oobe_SystemProfileDesc => ResourceManager.GetString(nameof(Oobe_SystemProfileDesc), Culture)!;
+ public static string Oobe_Portable => ResourceManager.GetString(nameof(Oobe_Portable), Culture)!;
+ public static string Oobe_PortableDesc => ResourceManager.GetString(nameof(Oobe_PortableDesc), Culture)!;
+ public static string Oobe_NotWritable => ResourceManager.GetString(nameof(Oobe_NotWritable), Culture)!;
+ public static string Oobe_NotWritableDesc => ResourceManager.GetString(nameof(Oobe_NotWritableDesc), Culture)!;
+ public static string Oobe_StartupTitle => ResourceManager.GetString(nameof(Oobe_StartupTitle), Culture)!;
+ public static string Oobe_ShowInTaskbar => ResourceManager.GetString(nameof(Oobe_ShowInTaskbar), Culture)!;
+ public static string Oobe_SlideTransition => ResourceManager.GetString(nameof(Oobe_SlideTransition), Culture)!;
+ public static string Oobe_FadeTransition => ResourceManager.GetString(nameof(Oobe_FadeTransition), Culture)!;
+ public static string Oobe_FusedDesktop => ResourceManager.GetString(nameof(Oobe_FusedDesktop), Culture)!;
+ public static string Oobe_AutoStart => ResourceManager.GetString(nameof(Oobe_AutoStart), Culture)!;
+ public static string Oobe_PrivacyTitle => ResourceManager.GetString(nameof(Oobe_PrivacyTitle), Culture)!;
+ public static string Oobe_CrashReports => ResourceManager.GetString(nameof(Oobe_CrashReports), Culture)!;
+ public static string Oobe_UsageStats => ResourceManager.GetString(nameof(Oobe_UsageStats), Culture)!;
+ public static string Oobe_PrivacyTrackingId => ResourceManager.GetString(nameof(Oobe_PrivacyTrackingId), Culture)!;
+ public static string Oobe_Agree => ResourceManager.GetString(nameof(Oobe_Agree), Culture)!;
+ public static string Oobe_PrivacyPolicyLink => ResourceManager.GetString(nameof(Oobe_PrivacyPolicyLink), Culture)!;
+ public static string Oobe_ButtonBack => ResourceManager.GetString(nameof(Oobe_ButtonBack), Culture)!;
+ public static string Oobe_ButtonNext => ResourceManager.GetString(nameof(Oobe_ButtonNext), Culture)!;
+ public static string Oobe_CompleteTitle => ResourceManager.GetString(nameof(Oobe_CompleteTitle), Culture)!;
+ public static string Oobe_CompleteSubtitle => ResourceManager.GetString(nameof(Oobe_CompleteSubtitle), Culture)!;
+ public static string Oobe_MonetDesc => ResourceManager.GetString(nameof(Oobe_MonetDesc), Culture)!;
+ public static string Oobe_MonetFromWallpaperDesc => ResourceManager.GetString(nameof(Oobe_MonetFromWallpaperDesc), Culture)!;
+ public static string Oobe_MonetFromCustomImageDesc => ResourceManager.GetString(nameof(Oobe_MonetFromCustomImageDesc), Culture)!;
+ public static string Oobe_MonetDisabledDesc => ResourceManager.GetString(nameof(Oobe_MonetDisabledDesc), Culture)!;
+ public static string Oobe_DataLocationDesc => ResourceManager.GetString(nameof(Oobe_DataLocationDesc), Culture)!;
+ public static string Oobe_StartupDesc => ResourceManager.GetString(nameof(Oobe_StartupDesc), Culture)!;
+ public static string Oobe_ShowInTaskbarDesc => ResourceManager.GetString(nameof(Oobe_ShowInTaskbarDesc), Culture)!;
+ public static string Oobe_SlideTransitionDesc => ResourceManager.GetString(nameof(Oobe_SlideTransitionDesc), Culture)!;
+ public static string Oobe_FadeTransitionDesc => ResourceManager.GetString(nameof(Oobe_FadeTransitionDesc), Culture)!;
+ public static string Oobe_FusedDesktopDesc => ResourceManager.GetString(nameof(Oobe_FusedDesktopDesc), Culture)!;
+ public static string Oobe_AutoStartDesc => ResourceManager.GetString(nameof(Oobe_AutoStartDesc), Culture)!;
+ public static string Oobe_AutoStartDescNonWindows => ResourceManager.GetString(nameof(Oobe_AutoStartDescNonWindows), Culture)!;
+ public static string Oobe_PrivacyDesc => ResourceManager.GetString(nameof(Oobe_PrivacyDesc), Culture)!;
+ public static string Oobe_CrashReportsDesc => ResourceManager.GetString(nameof(Oobe_CrashReportsDesc), Culture)!;
+ public static string Oobe_UsageStatsDesc => ResourceManager.GetString(nameof(Oobe_UsageStatsDesc), Culture)!;
+ public static string Oobe_PrivacyTrackingIdDesc => ResourceManager.GetString(nameof(Oobe_PrivacyTrackingIdDesc), Culture)!;
+ public static string Oobe_PrivacyAgreementNote => ResourceManager.GetString(nameof(Oobe_PrivacyAgreementNote), Culture)!;
+ public static string Oobe_TypingAppName => ResourceManager.GetString(nameof(Oobe_TypingAppName), Culture)!;
+ public static string Oobe_TypingNextGen => ResourceManager.GetString(nameof(Oobe_TypingNextGen), Culture)!;
+ public static string Oobe_TypingDashboard => ResourceManager.GetString(nameof(Oobe_TypingDashboard), Culture)!;
+ public static string Oobe_MigrationDetected => ResourceManager.GetString(nameof(Oobe_MigrationDetected), Culture)!;
+ public static string Migration_Title => ResourceManager.GetString(nameof(Migration_Title), Culture)!;
+ public static string Migration_DetectedOldVersion => ResourceManager.GetString(nameof(Migration_DetectedOldVersion), Culture)!;
+ public static string Migration_DetectedDesc => ResourceManager.GetString(nameof(Migration_DetectedDesc), Culture)!;
+ public static string Migration_Version => ResourceManager.GetString(nameof(Migration_Version), Culture)!;
+ public static string Migration_Location => ResourceManager.GetString(nameof(Migration_Location), Culture)!;
+ public static string Migration_Type => ResourceManager.GetString(nameof(Migration_Type), Culture)!;
+ public static string Migration_Installed => ResourceManager.GetString(nameof(Migration_Installed), Culture)!;
+ public static string Migration_UninstallNote => ResourceManager.GetString(nameof(Migration_UninstallNote), Culture)!;
+ public static string Migration_ButtonViewLocation => ResourceManager.GetString(nameof(Migration_ButtonViewLocation), Culture)!;
+ public static string Migration_ButtonSkip => ResourceManager.GetString(nameof(Migration_ButtonSkip), Culture)!;
+ public static string Migration_ButtonUninstall => ResourceManager.GetString(nameof(Migration_ButtonUninstall), Culture)!;
+ public static string Migration_Portable => ResourceManager.GetString(nameof(Migration_Portable), Culture)!;
+ public static string Migration_Unknown => ResourceManager.GetString(nameof(Migration_Unknown), Culture)!;
+ public static string Migration_DetectedDescFormat => ResourceManager.GetString(nameof(Migration_DetectedDescFormat), Culture)!;
+ public static string Privacy_Title => ResourceManager.GetString(nameof(Privacy_Title), Culture)!;
+ public static string Privacy_Header => ResourceManager.GetString(nameof(Privacy_Header), Culture)!;
+ public static string Privacy_Description => ResourceManager.GetString(nameof(Privacy_Description), Culture)!;
+ public static string Privacy_ButtonClose => ResourceManager.GetString(nameof(Privacy_ButtonClose), Culture)!;
+ public static string DevDebug_Title => ResourceManager.GetString(nameof(DevDebug_Title), Culture)!;
+ public static string DevDebug_Splash => ResourceManager.GetString(nameof(DevDebug_Splash), Culture)!;
+ public static string DevDebug_Error => ResourceManager.GetString(nameof(DevDebug_Error), Culture)!;
+ public static string DevDebug_Update => ResourceManager.GetString(nameof(DevDebug_Update), Culture)!;
+ public static string DevDebug_Oobe => ResourceManager.GetString(nameof(DevDebug_Oobe), Culture)!;
+ public static string DevDebug_DataLocation => ResourceManager.GetString(nameof(DevDebug_DataLocation), Culture)!;
+ public static string DevDebug_EnableFeature => ResourceManager.GetString(nameof(DevDebug_EnableFeature), Culture)!;
+ public static string DevDebug_Open => ResourceManager.GetString(nameof(DevDebug_Open), Culture)!;
+ public static string DevDebug_SetAllViewMode => ResourceManager.GetString(nameof(DevDebug_SetAllViewMode), Culture)!;
+ public static string DevDebug_SetAllFunctionMode => ResourceManager.GetString(nameof(DevDebug_SetAllFunctionMode), Culture)!;
+ public static string DevDebug_Close => ResourceManager.GetString(nameof(DevDebug_Close), Culture)!;
+ public static string Coordinator_SlowDeviceMessage => ResourceManager.GetString(nameof(Coordinator_SlowDeviceMessage), Culture)!;
+ public static string Coordinator_RunningHostMessage => ResourceManager.GetString(nameof(Coordinator_RunningHostMessage), Culture)!;
+ public static string Preview_SplashInitializing => ResourceManager.GetString(nameof(Preview_SplashInitializing), Culture)!;
+ public static string Preview_SplashCheckingUpdates => ResourceManager.GetString(nameof(Preview_SplashCheckingUpdates), Culture)!;
+ public static string Preview_SplashCheckingPlugins => ResourceManager.GetString(nameof(Preview_SplashCheckingPlugins), Culture)!;
+ public static string Preview_SplashLaunchingHost => ResourceManager.GetString(nameof(Preview_SplashLaunchingHost), Culture)!;
+ public static string Preview_SplashReady => ResourceManager.GetString(nameof(Preview_SplashReady), Culture)!;
+ public static string Preview_ErrorMessage => ResourceManager.GetString(nameof(Preview_ErrorMessage), Culture)!;
+ public static string Preview_UpdateProcessing => ResourceManager.GetString(nameof(Preview_UpdateProcessing), Culture)!;
+ public static string Preview_ActivationConnecting => ResourceManager.GetString(nameof(Preview_ActivationConnecting), Culture)!;
+
+ private static CultureInfo? Culture => CultureInfo.CurrentUICulture;
+}
diff --git a/LanMountainDesktop.Launcher/Resources/Strings.en-US.resx b/LanMountainDesktop.Launcher/Resources/Strings.en-US.resx
new file mode 100644
index 0000000..53035d2
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Resources/Strings.en-US.resx
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ LanMountain Desktop
+ LanMountain Desktop
+ Initializing...
+ [Debug Mode] Splash Preview
+ LanMountain Desktop
+ Launcher could not confirm startup
+ LanMountain Desktop did not reach the expected startup state.
+ Startup recovery
+ You can inspect logs, wait for the current process, or activate the running desktop instance.
+ Diagnostic details
+ Open Logs
+ Copy
+ Wait
+ Exit
+ Retry
+ Activate
+ [Debug] Launcher error
+ Launcher could not find the desktop executable
+ Pick another executable in debug mode, inspect logs, or retry after fixing the deployment path.
+ Inspect logs, then retry once the previous startup attempt has fully finished.
+ Inspect logs or exit. Launcher will avoid creating another desktop process while the old one is still running.
+ Startup is still pending
+ The desktop process is still running, so Launcher will not start a second instance.
+ The desktop process is still running, so Launcher will not start a second instance. Current host PID: {0}.
+ LanMountain Desktop
+ LanMountain Desktop is already running
+ Launcher found an existing desktop instance and did not start another process.
+ Repeated launch
+ Your current setting is to show this prompt without opening the desktop automatically.
+ No second Host process was created.
+ Copy
+ Close
+ Open desktop
+ Existing host PID: {0}
Shell state: {1}
No second Host process was created.
+ Choose Data Location
+ Choose Data Location
+ Choose where launcher and desktop data should be stored. You can change this later in settings.
+ App folder is not writable
+ The current install directory requires elevated permissions. Data will be stored in the system user profile instead.
+ Store in the system user profile (Recommended)
+ Data stays tied to the current Windows user and remains intact across app reinstalls and updates.
+ Store next to the app
+ Useful for portable installs. The whole app folder can be moved to another machine together with its data.
+ Cancel
+ Confirm
+ Existing system data was detected. Choosing portable mode will migrate the current data automatically.
+ LanMountain Desktop - Loading Details
+ Starting LanMountain Desktop
+ Initializing...
+ Preparing components
+ Loading Items
+ Done
+ An error occurred while loading.
+ Details
+ Cancel
+ Ready
+ Loading plugins...
+ Loading components...
+ Loading resources...
+ Loading data...
+ Downloading...
+ Processing...
+ Done
+ Plugin
+ Component
+ Resource
+ Data
+ Network
+ Settings
+ System
+ Other
+ LanMountain Desktop - Update
+ LanMountain Desktop
+ Update
+ Updating, please wait...
+ Update complete
+ Update failed
+ An error occurred during the update
+ [Debug Mode] Update Page
+ Preview update progress interface
+ Verifying update...
+ Applying plugin upgrades...
+ Cleaning up old deployments...
+ Debug Mode
+ Debug Settings
+ Developer Mode
+ Automatically scan dev directories when enabled
+ On
+ Off
+ App Path
+ Not selected
+ Browse...
+ This feature is for developers only
+ Cancel
+ OK
+ Select LanMountainDesktop host executable
+ Welcome to LanMountain Desktop
+ Welcome to LanMountain Desktop
+ Your desktop, more than one side
+ Get Started
+ Personalize Your Desktop
+ Choose your preferred theme style. You can change it anytime in settings.
+ Appearance Mode
+ Light Mode
+ Dark Mode
+ Theme Color
+ Monet Color Source
+ Extract from wallpaper
+ Extract from custom image
+ Don't use Monet colors
+ Choose Data Location
+ Store in the system user profile (Recommended)
+ Data stays tied to the current Windows user and remains intact across app reinstalls and updates.
+ Store next to the app
+ Useful for portable installs. The whole app folder can be moved to another machine together with its data.
+ Cannot save to app directory
+ The current install directory requires elevated permissions. Data will be stored in the system user profile instead.
+ Startup & Display
+ Show main desktop window in taskbar
+ Show main window with slide transition
+ Use fade-in transition on startup
+ Fused desktop with swipe gesture
+ Automatically start LanMountain Desktop on Windows login
+ Information & Privacy
+ Send anonymous crash reports
+ Send anonymous usage statistics
+ Privacy Tracking ID
+ Agree
+ LanMountain Desktop Telemetry Privacy Data Collection Agreement
+ Back
+ Next
+ Welcome to LanMountain Desktop
+ Your desktop, more than one side
+ Automatically extract theme colors from your wallpaper for a seamless desktop experience
+ Analyze current wallpaper colors to generate a theme
+ Choose an image as the color source
+ Use a fixed preset theme color
+ Decide where to store app data. You can change this anytime in settings.
+ These options can be changed anytime in the desktop app Settings. Slide-in entrance is only available on Windows.
+ When enabled, a taskbar entry remains when minimized; when disabled, rely more on the tray icon.
+ Slide in from screen edge; mutually exclusive with fade-in.
+ Recommended when slide-in is not enabled.
+ Enable fused desktop and three-finger swipe gesture for edge pop-in and related experimental features (same as developer options in Settings).
+ Register this launcher as the current user's startup item (same registry entry as the optional installer task).
+ Only the preference is saved on this platform; use the system's app auto-start settings for actual auto-start behavior.
+ Choose whether to participate in the telemetry program and review the privacy policy
+ Help improve app stability; no personal identity information is included
+ Help understand feature usage and optimize product experience
+ This ID is used to anonymously identify your device and does not contain any personal information
+ You must read and agree to the privacy policy before enabling telemetry features. Telemetry data is used solely to improve app stability and optimize product experience, and does not contain any personal identity information.
+ LanMountain Desktop
+ Next Gen
+ Interactive Dashboard
+ Existing data detected. It will be migrated automatically when portable mode is selected.
+ LanMountain Desktop - Version Migration
+ Old version detected
+ An older version of LanMountain Desktop (0.8.4) was detected on your system. Uninstalling it is recommended to avoid conflicts.
+ Version:
+ Location:
+ Type:
+ Installed
+ Uninstalling the old version will not affect the new version. Your personal data will be preserved.
+ View Location
+ Skip for now
+ Uninstall Old Version
+ Portable
+ Unknown
+ An older version of LanMountain Desktop ({0}) was detected on your system. The new version uses a completely new architecture. Uninstalling the old version is recommended for a better experience.
+ LanMountain Desktop Telemetry Privacy Data Collection Agreement
+ LanMountain Desktop Telemetry Privacy Data Collection Agreement
+ Please read the following agreement carefully to understand how we collect, use, and protect your data
+ Close
+ Developer Debug Window
+ Splash Screen
+ Error Page
+ Update Page
+ OOBE Page
+ Data Location
+ Enable Feature
+ Open
+ Set All to View Mode
+ Set All to Function Mode
+ Close
+ The device is slow and still starting up. Please wait.
+ The desktop process is still running. Launcher will continue waiting and will not start again.
+ Initializing...
+ Checking updates...
+ Checking plugins...
+ Launching host...
+ Ready
+ [Preview] This is the launcher error window preview.
+ Processing {0}...
+ Connecting to the active launcher...
+
diff --git a/LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx b/LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx
new file mode 100644
index 0000000..67860a5
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ 蘭山デスクトップ
+ 蘭山デスクトップ
+ 初期化中...
+ [デバッグモード] スプラッシュプレビュー
+ 蘭山デスクトップ
+ ランチャーは起動を確認できませんでした
+ 蘭山デスクトップが予期された起動状態に達しませんでした。
+ 起動の回復
+ ログを確認するか、現在のプロセスを待機するか、実行中のデスクトップインスタンスをアクティブ化できます。
+ 診断の詳細
+ ログを開く
+ コピー
+ 待機
+ 終了
+ 再試行
+ アクティブ化
+ [デバッグ] ランチャーエラー
+ ランチャーはデスクトップ実行可能ファイルを見つけられませんでした
+ デバッグモードで別の実行可能ファイルを選択するか、ログを確認するか、デプロイパスを修正して再試行してください。
+ ログを確認し、前回の起動試行が完全に終了してから再試行してください。
+ ログを確認するか終了してください。古いプロセスがまだ実行中の場合、ランチャーは新しいデスクトッププロセスを作成しません。
+ 起動はまだ保留中です
+ デスクトッププロセスがまだ実行中のため、ランチャーは2番目のインスタンスを起動しません。
+ デスクトッププロセスがまだ実行中のため、ランチャーは2番目のインスタンスを起動しません。現在のホスト PID: {0}。
+ 蘭山デスクトップ
+ 蘭山デスクトップは既に実行中です
+ ランチャーは既存のデスクトップインスタンスを検出し、新しいプロセスを起動しませんでした。
+ 重複起動
+ 現在の設定では、デスクトップを自動的に開かずにこのプロンプトを表示します。
+ 2番目のホストプロセスは作成されませんでした。
+ コピー
+ 閉じる
+ デスクトップを開く
+ 既存のホスト PID: {0}
シェル状態: {1}
2番目のホストプロセスは作成されませんでした。
+ データ保存場所の選択
+ データ保存場所の選択
+ ランチャーとデスクトップデータの保存場所を選択してください。後から設定で変更できます。
+ アプリフォルダに書き込みできません
+ 現在のインストールディレクトリには昇格された権限が必要です。データはシステムユーザープロファイルに保存されます。
+ システムユーザープロファイルに保存(推奨)
+ データは現在のWindowsユーザーに紐付けられ、アプリの再インストールや更新後も保持されます。
+ アプリの横に保存(ポータブル)
+ ポータブルインストールに便利です。アプリフォルダ全体をデータと一緒に別のマシンに移動できます。
+ キャンセル
+ 確認
+ 既存のシステムデータが検出されました。ポータブルモードを選択すると、現在のデータが自動的に移行されます。
+ 蘭山デスクトップ - 読み込み詳細
+ 蘭山デスクトップを起動中
+ 初期化中...
+ コンポーネントを準備中
+ 読み込み項目
+ 完了
+ 読み込み中にエラーが発生しました。
+ 詳細
+ キャンセル
+ 準備完了
+ プラグインを読み込み中...
+ コンポーネントを読み込み中...
+ リソースを読み込み中...
+ データを読み込み中...
+ ダウンロード中...
+ 処理中...
+ 完了
+ プラグイン
+ コンポーネント
+ リソース
+ データ
+ ネットワーク
+ 設定
+ システム
+ その他
+ 蘭山デスクトップ - 更新
+ 蘭山デスクトップ
+ 更新
+ 更新中、お待ちください...
+ 更新完了
+ 更新失敗
+ 更新中にエラーが発生しました
+ [デバッグモード] 更新ページ
+ 更新進行インターフェースのプレビュー
+ 更新を検証中...
+ プラグインのアップグレードを適用中...
+ 古いデプロイメントをクリーンアップ中...
+ デバッグモード
+ デバッグ設定
+ 開発者モード
+ 有効にすると開発ディレクトリを自動スキャンします
+ オン
+ オフ
+ アプリパス
+ 未選択
+ 参照...
+ この機能は開発者専用です
+ キャンセル
+ OK
+ 蘭山デスクトップホスト実行可能ファイルを選択
+ 蘭山デスクトップへようこそ
+ 蘭山デスクトップへようこそ
+ あなたのデスクトップ、一面だけじゃない
+ 始める
+ デスクトップをカスタマイズ
+ お好みのテーマスタイルを選択してください。設定でいつでも変更できます。
+ 外観モード
+ ライトモード
+ ダークモード
+ テーマカラー
+ Monet カラーソース
+ 壁紙から抽出
+ カスタム画像から抽出
+ Monet カラーを使用しない
+ データ保存場所の選択
+ システムユーザープロファイルに保存(推奨)
+ データは現在のWindowsユーザーに紐付けられ、アプリの再インストールや更新後も保持されます。
+ アプリの横に保存(ポータブル)
+ ポータブルインストールに便利です。アプリフォルダ全体をデータと一緒に別のマシンに移動できます。
+ アプリディレクトリに保存できません
+ 現在のインストールディレクトリには昇格された権限が必要です。データはシステムユーザープロファイルに保存されます。
+ 起動と表示
+ タスクバーにメインデスクトップウィンドウを表示
+ スライド遷移でメインウィンドウを表示
+ 起動時にフェードイン遷移を使用
+ フューズドデスクトップとスワイプジェスチャー
+ Windowsログイン時に蘭山デスクトップを自動起動
+ 情報とプライバシー
+ 匿名クラッシュレポートを送信
+ 匿名使用統計を送信
+ プライバシー追跡 ID
+ 同意する
+ 蘭山デスクトップテレメトリプライバシーデータ収集同意書
+ 戻る
+ 次へ
+ 蘭山デスクトップへようこそ
+ あなたのデスクトップ、一面だけじゃない
+ 壁紙からテーマカラーを自動抽出し、デスクトップとシームレスに融合
+ 現在の壁紙の色を分析してテーマを生成
+ 画像を選択してカラーソースにする
+ 固定のプリセットテーマカラーを使用
+ アプリデータの保存場所を決定します。設定でいつでも変更できます。
+ これらのオプションはデスクトップアプリの設定でいつでも変更できます。スライドインはWindowsでのみ利用可能です。
+ 有効にすると最小化時にタスクバーにエントリが残ります。無効にするとトレイアイコンに依存します。
+ 画面端からスライドイン。フェードインとは排他的。
+ スライドインが無効の場合に推奨。
+ フューズドデスクトップと三本指スワイプジェスチャーを有効にし、エッジポップインと関連実験機能を使用します(設定の開発者オプションと同じ)。
+ このランチャーを現在のユーザーのスタートアップ項目として登録します(インストーラーのオプションタスクと同じレジストリエントリ)。
+ このプラットフォームでは設定のみ保存されます。自動起動にはシステムのアプリ自動起動設定を使用してください。
+ テレメトリプログラムへの参加を選択し、プライバシーポリシーを確認
+ アプリの安定性向上に協力。個人情報は含まれません
+ 機能の使用状況を把握し、製品体験を最適化
+ このIDはデバイスを匿名で識別するために使用され、個人情報は含まれません
+ テレメトリ機能を有効にする前に、プライバシーポリシーを読んで同意する必要があります。テレメトリデータはアプリの安定性向上と製品体験の最適化にのみ使用され、個人情報は含まれません。
+ 蘭山デスクトップ LanMountain Desktop
+ 次世代
+ インタラクティブダッシュボード
+ 既存データが検出されました。ポータブルモード選択時に自動移行されます。
+ 蘭山デスクトップ - バージョン移行
+ 旧バージョンを検出
+ システムに旧バージョンの蘭山デスクトップ(0.8.4)が検出されました。競合を避けるためにアンインストールをお勧めします。
+ バージョン:
+ 場所:
+ タイプ:
+ インストール版
+ 旧バージョンをアンインストールしても新バージョンには影響しません。個人データは保持されます。
+ 場所を表示
+ 後で
+ 旧バージョンをアンインストール
+ ポータブル版
+ 不明
+ システムに旧バージョンの蘭山デスクトップ({0})が検出されました。新バージョンは完全に新しいアーキテクチャを採用しています。より良い体験のために旧バージョンのアンインストールをお勧めします。
+ 蘭山デスクトップテレメトリプライバシーデータ収集同意書
+ 蘭山デスクトップテレメトリプライバシーデータ収集同意書
+ 以下の同意書をよくお読みになり、データの収集、使用、保護についてご理解ください
+ 閉じる
+ 開発者デバッグウィンドウ
+ スプラッシュ画面
+ エラーページ
+ 更新ページ
+ OOBEページ
+ データ保存場所
+ 機能を有効化
+ 開く
+ すべて表示モードに設定
+ すべて機能モードに設定
+ 閉じる
+ デバイスの動作が遅く、まだ起動中です。お待ちください。
+ デスクトッププロセスがまだ実行中です。ランチャーは待機を続け、再起動しません。
+ 初期化中...
+ 更新を確認中...
+ プラグインを確認中...
+ ホストを起動中...
+ 準備完了
+ [プレビュー] ランチャーエラーウィンドウのプレビューです。
+ {0} を処理中...
+ アクティブなランチャーに接続中...
+
diff --git a/LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx b/LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx
new file mode 100644
index 0000000..5d9053f
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ 란산 데스크톱
+ 란산 데스크톱
+ 초기화 중...
+ [디버그 모드] 스플래시 미리보기
+ 란산 데스크톱
+ 런처가 시작을 확인할 수 없습니다
+ 란산 데스크톱이 예상된 시작 상태에 도달하지 못했습니다.
+ 시작 복구
+ 로그를 검사하거나, 현재 프로세스를 대기하거나, 실행 중인 데스크톱 인스턴스를 활성화할 수 있습니다.
+ 진단 세부정보
+ 로그 열기
+ 복사
+ 대기
+ 종료
+ 재시도
+ 활성화
+ [디버그] 런처 오류
+ 런처가 데스크톱 실행 파일을 찾을 수 없습니다
+ 디버그 모드에서 다른 실행 파일을 선택하거나, 로그를 검사하거나, 배포 경로를 수정한 후 재시도하세요.
+ 로그를 검사한 후 이전 시작 시도가 완전히 끝나면 재시도하세요.
+ 로그를 검사하거나 종료하세요. 이전 프로세스가 여전히 실행 중인 동안 런처는 새 데스크톱 프로세스를 생성하지 않습니다.
+ 시작이 아직 보류 중입니다
+ 데스크톱 프로세스가 여전히 실행 중이므로 런처는 두 번째 인스턴스를 시작하지 않습니다.
+ 데스크톱 프로세스가 여전히 실행 중이므로 런처는 두 번째 인스턴스를 시작하지 않습니다. 현재 호스트 PID: {0}.
+ 란산 데스크톱
+ 란산 데스크톱이 이미 실행 중입니다
+ 런처가 기존 데스크톱 인스턴스를 감지하여 새 프로세스를 시작하지 않았습니다.
+ 중복 실행
+ 현재 설정은 데스크톱을 자동으로 열지 않고 이 프롬프트를 표시하는 것입니다.
+ 두 번째 호스트 프로세스가 생성되지 않았습니다.
+ 복사
+ 닫기
+ 데스크톱 열기
+ 기존 호스트 PID: {0}
셸 상태: {1}
두 번째 호스트 프로세스가 생성되지 않았습니다.
+ 데이터 저장 위치 선택
+ 데이터 저장 위치 선택
+ 런처와 데스크톱 데이터가 저장될 위치를 선택하세요. 나중에 설정에서 변경할 수 있습니다.
+ 앱 폴더에 쓸 수 없습니다
+ 현재 설치 디렉토리는 상승된 권한이 필요합니다. 데이터는 시스템 사용자 프로필에 저장됩니다.
+ 시스템 사용자 프로필에 저장 (권장)
+ 데이터는 현재 Windows 사용자에 연결되며, 앱 재설치 및 업데이트 후에도 그대로 유지됩니다.
+ 앱 옆에 저장 (휴대용)
+ 휴대용 설치에 유용합니다. 전체 앱 폴더를 데이터와 함께 다른 컴퓨터로 이동할 수 있습니다.
+ 취소
+ 확인
+ 기존 시스템 데이터가 감지되었습니다. 휴대용 모드를 선택하면 현재 데이터가 자동으로 마이그레이션됩니다.
+ 란산 데스크톱 - 로딩 세부정보
+ 란산 데스크톱 시작 중
+ 초기화 중...
+ 구성 요소 준비 중
+ 로딩 항목
+ 완료
+ 로딩 중 오류가 발생했습니다.
+ 세부정보
+ 취소
+ 준비 완료
+ 플러그인 로딩 중...
+ 구성 요소 로딩 중...
+ 리소스 로딩 중...
+ 데이터 로딩 중...
+ 다운로드 중...
+ 처리 중...
+ 완료
+ 플러그인
+ 구성 요소
+ 리소스
+ 데이터
+ 네트워크
+ 설정
+ 시스템
+ 기타
+ 란산 데스크톱 - 업데이트
+ 란산 데스크톱
+ 업데이트
+ 업데이트 중, 잠시 기다려 주세요...
+ 업데이트 완료
+ 업데이트 실패
+ 업데이트 중 오류가 발생했습니다
+ [디버그 모드] 업데이트 페이지
+ 업데이트 진행 인터페이스 미리보기
+ 업데이트 확인 중...
+ 플러그인 업그레이드 적용 중...
+ 이전 배포 정리 중...
+ 디버그 모드
+ 디버그 설정
+ 개발자 모드
+ 활성화 시 개발 디렉토리 자동 스캔
+ 켜기
+ 끄기
+ 앱 경로
+ 선택 안 됨
+ 찾아보기...
+ 이 기능은 개발자 전용입니다
+ 취소
+ 확인
+ 란산 데스크톱 호스트 실행 파일 선택
+ 란산 데스크톱에 오신 것을 환영합니다
+ 란산 데스크톱에 오신 것을 환영합니다
+ 당신의 데스크톱, 한 면이 아닙니다
+ 시작하기
+ 데스크톱 개인화
+ 원하는 테마 스타일을 선택하세요. 설정에서 언제든 변경할 수 있습니다.
+ 외관 모드
+ 라이트 모드
+ 다크 모드
+ 테마 색상
+ Monet 색상 소스
+ 바탕 화면에서 추출
+ 사용자 지정 이미지에서 추출
+ Monet 색상 사용 안 함
+ 데이터 저장 위치 선택
+ 시스템 사용자 프로필에 저장 (권장)
+ 데이터는 현재 Windows 사용자에 연결되며, 앱 재설치 및 업데이트 후에도 그대로 유지됩니다.
+ 앱 옆에 저장 (휴대용)
+ 휴대용 설치에 유용합니다. 전체 앱 폴더를 데이터와 함께 다른 컴퓨터로 이동할 수 있습니다.
+ 앱 디렉토리에 저장할 수 없습니다
+ 현재 설치 디렉토리는 상승된 권한이 필요합니다. 데이터는 시스템 사용자 프로필에 저장됩니다.
+ 시작 및 표시
+ 작업 표시줄에 기본 데스크톱 창 표시
+ 슬라이드 전환으로 기본 창 표시
+ 시작 시 페이드인 전환 사용
+ 퓨즈드 데스크톱 및 스와이프 제스처
+ Windows 로그인 시 란산 데스크톱 자동 시작
+ 정보 및 개인정보
+ 익명 크래시 보고서 보내기
+ 익명 사용 통계 보내기
+ 개인정보 추적 ID
+ 동의
+ 란산 데스크톱 원격 측정 개인정보 데이터 수집 동의서
+ 뒤로
+ 다음
+ 란산 데스크톱에 오신 것을 환영합니다
+ 당신의 데스크톱, 한 면이 아닙니다
+ 바탕 화면에서 테마 색상을 자동 추출하여 데스크톱과 완벽하게 융합
+ 현재 바탕 화면 색상을 분석하여 테마 생성
+ 이미지를 선택하여 색상 소스로 사용
+ 고정된 프리셋 테마 색상 사용
+ 앱 데이터를 저장할 위치를 결정하세요. 설정에서 언제든 변경할 수 있습니다.
+ 이 옵션은 데스크톱 앱 설정에서 언제든 변경할 수 있습니다. 슬라이드 인은 Windows에서만 사용 가능합니다.
+ 활성화하면 최소화 시 작업 표시줄에 항목이 유지됩니다. 비활성화하면 트레이 아이콘에 더 의존합니다.
+ 화면 가장자리에서 슬라이드 인. 페이드 인과 상호 배타적.
+ 슬라이드 인이 비활성화된 경우 권장.
+ 퓨즈드 데스크톱과 세 손가락 스와이프 제스처를 활성화하여 에지 팝인 및 관련 실험 기능을 사용합니다(설정의 개발자 옵션과 동일).
+ 이 런처를 현재 사용자의 시작 프로그램으로 등록합니다(설치 프로그램의 선택적 작업과 동일한 레지스트리 항목).
+ 이 플랫폼에서는 설정만 저장됩니다. 자동 시작은 시스템의 앱 자동 시작 설정을 사용하세요.
+ 원격 측정 프로그램 참여 여부를 선택하고 개인정보 정책을 확인
+ 앱 안정성 개선에 도움. 개인 식별 정보는 포함되지 않습니다
+ 기능 사용 현황을 파악하여 제품 경험 최적화
+ 이 ID는 기기를 익명으로 식별하는 데 사용되며 개인 정보는 포함되지 않습니다
+ 원격 측정 기능을 활성화하려면 먼저 개인정보 정책을 읽고 동의해야 합니다. 원격 측정 데이터는 앱 안정성 개선 및 제품 경험 최적화에만 사용되며 개인 식별 정보는 포함되지 않습니다.
+ 란산 데스크톱 LanMountain Desktop
+ 차세대
+ 인터랙티브 대시보드
+ 기존 데이터가 감지되었습니다. 휴대용 모드 선택 시 자동으로 마이그레이션됩니다.
+ 란산 데스크톱 - 버전 마이그레이션
+ 이전 버전 감지됨
+ 시스템에 이전 버전의 란산 데스크톱(0.8.4)이 감지되었습니다. 충돌을 방지하기 위해 제거를 권장합니다.
+ 버전:
+ 위치:
+ 유형:
+ 설치 버전
+ 이전 버전을 제거해도 새 버전에는 영향을 미치지 않습니다. 개인 데이터는 보존됩니다.
+ 위치 보기
+ 나중에
+ 이전 버전 제거
+ 휴대용 버전
+ 알 수 없음
+ 시스템에 이전 버전의 란산 데스크톱({0})이 감지되었습니다. 새 버전은 완전히 새로운 아키텍처를 사용합니다. 더 나은 경험을 위해 이전 버전 제거를 권장합니다.
+ 란산 데스크톱 원격 측정 개인정보 데이터 수집 동의서
+ 란산 데스크톱 원격 측정 개인정보 데이터 수집 동의서
+ 데이터의 수집, 사용 및 보호 방법을 이해하기 위해 다음 동의서를 주의 깊게 읽어주세요
+ 닫기
+ 개발자 디버그 창
+ 시작 화면
+ 오류 페이지
+ 업데이트 페이지
+ OOBE 페이지
+ 데이터 위치
+ 기능 활성화
+ 열기
+ 모두 보기 모드로 설정
+ 모두 기능 모드로 설정
+ 닫기
+ 장치가 느려 여전히 시작 중입니다. 잠시 기다려주세요.
+ 데스크톱 프로세스가 여전히 실행 중입니다. 런처는 계속 대기하며 다시 시작하지 않습니다.
+ 초기화 중...
+ 업데이트 확인 중...
+ 플러그인 확인 중...
+ 호스트 시작 중...
+ 준비 완료
+ [미리보기] 런처 오류 창 미리보기입니다.
+ {0} 처리 중...
+ 활성 런처에 연결 중...
+
diff --git a/LanMountainDesktop.Launcher/Resources/Strings.resx b/LanMountainDesktop.Launcher/Resources/Strings.resx
new file mode 100644
index 0000000..4107fea
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Resources/Strings.resx
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ 阑山桌面
+ 阑山桌面
+ 正在初始化...
+ [调试模式] 启动画面预览
+ 阑山桌面
+ 启动器无法确认启动状态
+ 阑山桌面未达到预期的启动状态。
+ 启动恢复
+ 您可以检查日志、等待当前进程或激活正在运行的桌面实例。
+ 诊断详情
+ 打开日志
+ 复制
+ 等待
+ 退出
+ 重试
+ 激活
+ [调试] 启动器错误
+ 启动器找不到桌面可执行文件
+ 在调试模式下选择另一个可执行文件、检查日志,或在修复部署路径后重试。
+ 检查日志后重试,等待上一次启动尝试完全结束。
+ 检查日志或退出。旧进程仍在运行时,启动器不会创建新的桌面进程。
+ 启动仍在进行中
+ 桌面进程仍在运行,启动器不会启动第二个实例。
+ 桌面进程仍在运行,启动器不会启动第二个实例。当前主进程 PID: {0}。
+ 阑山桌面
+ 阑山桌面已在运行
+ 启动器检测到已存在的桌面实例,未启动新进程。
+ 重复启动
+ 您当前的设置为显示此提示而不自动打开桌面。
+ 未创建第二个主进程。
+ 复制
+ 关闭
+ 打开桌面
+ 现有主进程 PID: {0}
Shell 状态: {1}
未创建第二个主进程。
+ 选择数据保存位置
+ 选择数据保存位置
+ 选择启动器和桌面数据的存储位置。您可以稍后在设置中更改。
+ 应用目录不可写入
+ 当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。
+ 保存在系统用户目录(推荐)
+ 数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。
+ 保存在应用安装目录(便携模式)
+ 适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。
+ 取消
+ 确认
+ 检测到已有的系统数据。选择便携模式将自动迁移当前数据。
+ 阑山桌面 - 加载详情
+ 正在启动阑山桌面
+ 正在初始化...
+ 正在准备组件
+ 加载项目
+ 完成
+ 加载时发生错误。
+ 详情
+ 取消
+ 准备就绪
+ 正在加载插件...
+ 正在加载组件...
+ 正在加载资源...
+ 正在加载数据...
+ 正在下载...
+ 正在处理...
+ 完成
+ 插件
+ 组件
+ 资源
+ 数据
+ 网络
+ 设置
+ 系统
+ 其他
+ 阑山桌面 - 更新
+ 阑山桌面
+ 更新
+ 正在更新,请稍候...
+ 更新完成
+ 更新失败
+ 更新过程中发生错误
+ [调试模式] 更新页面
+ 预览更新进度界面
+ 正在验证更新...
+ 正在应用插件升级...
+ 正在清理旧部署...
+ 调试模式
+ 调试设置
+ 开发模式
+ 启用后自动扫描开发目录
+ 开
+ 关
+ 应用路径
+ 未选择
+ 浏览...
+ 此功能仅供开发人员使用
+ 取消
+ 确定
+ 选择阑山桌面主程序可执行文件
+ 欢迎使用阑山桌面
+ 欢迎使用阑山桌面
+ 你的桌面,不止一面
+ 开始使用
+ 个性化你的桌面
+ 选择你喜欢的主题样式,可随时在设置中更改
+ 外观模式
+ 浅色模式
+ 深色模式
+ 主题色
+ 莫奈取色来源
+ 从桌面壁纸取色
+ 自定义图片取色
+ 不使用莫奈取色
+ 选择数据保存位置
+ 保存在系统用户目录(推荐)
+ 数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。
+ 保存在应用安装目录(便携模式)
+ 适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。
+ 无法保存到应用目录
+ 当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。
+ 启动与展示
+ 在任务栏显示主桌面窗口
+ 以滑动方式显示主窗口
+ 启动时使用淡入过渡
+ 融合桌面与弹入手势
+ 登录 Windows 时自动启动阑山桌面
+ 信息与隐私
+ 发送匿名崩溃报告
+ 发送匿名使用统计
+ 隐私追踪 ID
+ 同意
+ 《阑山桌面遥测隐私数据收集协议》
+ 返回
+ 下一步
+ 欢迎使用阑山桌面
+ 你的桌面,不止一面
+ 从壁纸自动提取主题色,让界面与桌面完美融合
+ 自动分析当前壁纸颜色生成主题
+ 选择一张图片作为取色来源
+ 使用固定的预设主题色
+ 决定将应用数据保存在哪里,可随时在设置中更改
+ 这些选项可随时在桌面应用的「设置」中更改。主窗口滑动入场仅在 Windows 上可用。
+ 开启后最小化时可在任务栏保留条目;关闭则更多依赖托盘图标。
+ 自屏幕边缘滑入;与「淡入」二选一。
+ 在未启用滑动入场时建议使用。
+ 同时启用融合桌面与三指滑动手势,以便使用边缘弹入与相关实验特性(与设置中开发者选项一致)。
+ 通过当前用户的启动项注册本启动器(与安装程序可选任务使用同一注册表项)。
+ 当前平台仅保存偏好;是否随系统自启动请使用系统提供的应用自启动设置。
+ 选择是否参与遥测计划,查看隐私政策
+ 帮助改进应用稳定性,不包含个人身份信息
+ 帮助了解功能使用情况,优化产品体验
+ 此 ID 用于匿名标识您的设备,不包含任何个人信息
+ 您必须阅读并同意隐私协议后,才能开启遥测功能。遥测数据仅用于改进应用稳定性和优化产品体验,不包含任何个人身份信息。
+ 阑山桌面 LanMountain Desktop
+ 下一代
+ 互动信息看板
+ 检测到现有数据,选择便携模式时将自动迁移。
+ 阑山桌面 - 版本迁移
+ 检测到旧版本
+ 检测到您的系统中安装了旧版本的阑山桌面(0.8.4),建议卸载以避免冲突。
+ 版本:
+ 位置:
+ 类型:
+ 安装版
+ 卸载旧版本不会影响新版本的使用,您的个人数据将保留。
+ 查看位置
+ 暂不处理
+ 卸载旧版本
+ 便携版
+ 未知
+ 检测到您的系统中安装了旧版本的阑山桌面({0})。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。
+ 阑山桌面遥测隐私数据收集协议
+ 阑山桌面遥测隐私数据收集协议
+ 请仔细阅读以下协议内容,了解我们如何收集、使用和保护您的数据
+ 关闭
+ 开发调试窗口
+ 启动画面
+ 错误页面
+ 更新页面
+ OOBE页面
+ 数据位置选择
+ 启用功能
+ 打开
+ 全部设为查看模式
+ 全部设为功能模式
+ 关闭
+ 设备较慢,仍在启动,请稍候。
+ 桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。
+ 正在初始化...
+ 正在检查更新...
+ 正在检查插件...
+ 正在启动主程序...
+ 准备就绪
+ [预览] 这是启动器错误窗口预览。
+ 正在处理 {0}...
+ 正在连接到活跃的启动器...
+
diff --git a/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs b/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs
new file mode 100644
index 0000000..ebd0b31
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/AirApp/AirAppHostLocator.cs
@@ -0,0 +1,90 @@
+namespace LanMountainDesktop.Launcher.Services.AirApp;
+
+internal sealed class AirAppHostLocator
+{
+ private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
+ private const string DllName = "LanMountainDesktop.AirAppHost.dll";
+
+ public string Resolve(string? packageRoot, string? hostPath = null)
+ {
+ foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
+ {
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ throw new FileNotFoundException("Unable to find LanMountainDesktop.AirAppHost output.");
+ }
+
+ private static IEnumerable EnumerateCandidates(string? packageRoot, string? hostPath)
+ {
+ foreach (var root in EnumerateRoots(packageRoot, hostPath))
+ {
+ yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
+ yield return Path.Combine(root, "AirAppHost", DllName);
+ yield return Path.Combine(root, WindowsExecutableName);
+ yield return Path.Combine(root, DllName);
+
+ if (Directory.Exists(root))
+ {
+ foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
+ {
+ yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
+ yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
+ yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
+ yield return Path.Combine(deploymentDirectory, DllName);
+ }
+ }
+ }
+
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent)
+ {
+ yield return Path.Combine(
+ current.FullName,
+ "LanMountainDesktop.AirAppHost",
+ "bin",
+#if DEBUG
+ "Debug",
+#else
+ "Release",
+#endif
+ "net10.0",
+ WindowsExecutableName);
+
+ yield return Path.Combine(
+ current.FullName,
+ "LanMountainDesktop.AirAppHost",
+ "bin",
+#if DEBUG
+ "Debug",
+#else
+ "Release",
+#endif
+ "net10.0",
+ DllName);
+ }
+ }
+
+ private static IEnumerable EnumerateRoots(string? packageRoot, string? hostPath)
+ {
+ if (!string.IsNullOrWhiteSpace(packageRoot))
+ {
+ yield return Path.GetFullPath(packageRoot);
+ }
+
+ if (!string.IsNullOrWhiteSpace(hostPath))
+ {
+ var hostDirectory = Path.GetDirectoryName(Path.GetFullPath(hostPath));
+ if (!string.IsNullOrWhiteSpace(hostDirectory))
+ {
+ yield return hostDirectory;
+ }
+ }
+
+ yield return AppContext.BaseDirectory;
+ yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs b/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs
new file mode 100644
index 0000000..87e49c2
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/AirApp/AirAppInstanceKey.cs
@@ -0,0 +1,24 @@
+namespace LanMountainDesktop.Launcher.Services.AirApp;
+
+internal static class AirAppInstanceKey
+{
+ public static string Build(string appId, string? sourceComponentId, string? sourcePlacementId)
+ {
+ var normalizedAppId = Normalize(appId, "unknown");
+ if (string.Equals(normalizedAppId, "world-clock", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{normalizedAppId}:clock-suite:global";
+ }
+
+ var normalizedComponentId = Normalize(sourceComponentId, "none");
+ var normalizedPlacementId = Normalize(sourcePlacementId, "none");
+ return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}";
+ }
+
+ private static string Normalize(string? value, string fallback)
+ {
+ return string.IsNullOrWhiteSpace(value)
+ ? fallback
+ : value.Trim();
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs
new file mode 100644
index 0000000..6e00ea2
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs
@@ -0,0 +1,102 @@
+using System.Diagnostics;
+
+namespace LanMountainDesktop.Launcher.Services.AirApp;
+
+internal interface IAirAppProcessStarter
+{
+ Process? Start(string appId, string sessionId, string instanceKey, string? sourceComponentId, string? sourcePlacementId);
+}
+
+internal sealed class AirAppProcessStarter : IAirAppProcessStarter
+{
+ private readonly AirAppHostLocator _locator;
+ private readonly Func _packageRootProvider;
+ private readonly Func _hostPathProvider;
+ private readonly Func _dataRootProvider;
+
+ public AirAppProcessStarter(
+ AirAppHostLocator locator,
+ Func packageRootProvider,
+ Func hostPathProvider,
+ Func dataRootProvider)
+ {
+ _locator = locator;
+ _packageRootProvider = packageRootProvider;
+ _hostPathProvider = hostPathProvider;
+ _dataRootProvider = dataRootProvider;
+ }
+
+ public Process? Start(
+ string appId,
+ string sessionId,
+ string instanceKey,
+ string? sourceComponentId,
+ string? sourcePlacementId)
+ {
+ var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
+ var startInfo = new ProcessStartInfo
+ {
+ UseShellExecute = false,
+ WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
+ };
+
+ if (OperatingSystem.IsWindows() &&
+ string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ startInfo.FileName = hostPath;
+ }
+ else
+ {
+ startInfo.FileName = "dotnet";
+ startInfo.ArgumentList.Add(hostPath);
+ }
+
+ AddArgument(startInfo, "--app-id", appId);
+ AddArgument(startInfo, "--session-id", sessionId);
+ AddArgument(startInfo, "--instance-key", instanceKey);
+ AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
+ var dataRoot = _dataRootProvider();
+ if (!string.IsNullOrWhiteSpace(dataRoot))
+ {
+ AddArgument(startInfo, "--data-root", Path.GetFullPath(dataRoot));
+ }
+
+ if (!string.IsNullOrWhiteSpace(sourceComponentId))
+ {
+ AddArgument(startInfo, "--source-component-id", sourceComponentId.Trim());
+ }
+
+ if (!string.IsNullOrWhiteSpace(sourcePlacementId))
+ {
+ AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
+ }
+
+ LanMountainDesktop.Launcher.Services.Logger.Info(
+ $"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
+ var process = Process.Start(startInfo);
+ if (process is not null)
+ {
+ process.EnableRaisingEvents = true;
+ process.Exited += (_, _) =>
+ {
+ try
+ {
+ LanMountainDesktop.Launcher.Services.Logger.Info(
+ $"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
+ }
+ catch (Exception ex)
+ {
+ LanMountainDesktop.Launcher.Services.Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
+ }
+ };
+ }
+
+ return process;
+ }
+
+ private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
+ {
+ startInfo.ArgumentList.Add(name);
+ startInfo.ArgumentList.Add(value);
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs
new file mode 100644
index 0000000..ec1b142
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleIpcHost.cs
@@ -0,0 +1,29 @@
+using LanMountainDesktop.Shared.IPC;
+using LanMountainDesktop.Shared.IPC.Abstractions.Services;
+
+namespace LanMountainDesktop.Launcher.Services.AirApp;
+
+internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
+{
+ private readonly PublicIpcHostService _host;
+
+ public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService)
+ {
+ LifecycleService = lifecycleService;
+ _host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName);
+ _host.RegisterPublicService(lifecycleService);
+ }
+
+ public LauncherAirAppLifecycleService LifecycleService { get; }
+
+ public void Start()
+ {
+ _host.Start();
+ Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
+ }
+
+ public void Dispose()
+ {
+ _host.Dispose();
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs
new file mode 100644
index 0000000..db45807
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/AirApp/LauncherAirAppLifecycleService.cs
@@ -0,0 +1,332 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using LanMountainDesktop.Shared.IPC.Abstractions.Services;
+
+namespace LanMountainDesktop.Launcher.Services.AirApp;
+
+internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
+{
+ private readonly object _gate = new();
+ private readonly IAirAppProcessStarter _processStarter;
+ private readonly Dictionary _instances = new(StringComparer.OrdinalIgnoreCase);
+
+ public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
+ {
+ _processStarter = processStarter;
+ }
+
+ public Task OpenAsync(AirAppOpenRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var appId = Normalize(request.AppId, "unknown");
+ var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
+ Logger.Info(
+ $"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
+
+ lock (_gate)
+ {
+ CleanupExitedInstances();
+
+ if (_instances.TryGetValue(instanceKey, out var existing) && IsProcessAlive(existing.ProcessId))
+ {
+ TryActivateProcess(existing.ProcessId);
+ existing.Touch();
+ return Task.FromResult(BuildResult(true, "activated_existing", "Activated existing Air APP instance.", existing));
+ }
+
+ var sessionId = Guid.NewGuid().ToString("N");
+ try
+ {
+ var process = _processStarter.Start(
+ appId,
+ sessionId,
+ instanceKey,
+ request.SourceComponentId,
+ request.SourcePlacementId);
+ if (process is null)
+ {
+ return Task.FromResult(BuildResult(false, "start_failed", "AirAppHost process was not created.", null));
+ }
+
+ var instance = new ManagedAirAppInstance(
+ instanceKey,
+ appId,
+ sessionId,
+ process.Id,
+ $"{appId} - Air APP",
+ request.SourceComponentId,
+ request.SourcePlacementId);
+ _instances[instanceKey] = instance;
+ Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
+ return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
+ return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
+ }
+ }
+ }
+
+ public Task ActivateAsync(string instanceKey)
+ {
+ lock (_gate)
+ {
+ CleanupExitedInstances();
+ if (!_instances.TryGetValue(instanceKey, out var instance))
+ {
+ return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
+ }
+
+ var accepted = TryActivateProcess(instance.ProcessId);
+ instance.Touch();
+ return Task.FromResult(BuildResult(
+ accepted,
+ accepted ? "activated" : "activation_failed",
+ accepted ? "Air APP instance activated." : "Failed to activate Air APP instance.",
+ instance));
+ }
+ }
+
+ public Task CloseAsync(string instanceKey)
+ {
+ lock (_gate)
+ {
+ CleanupExitedInstances();
+ if (!_instances.TryGetValue(instanceKey, out var instance))
+ {
+ return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
+ }
+
+ var accepted = TryCloseProcess(instance.ProcessId);
+ instance.Touch();
+ return Task.FromResult(BuildResult(
+ accepted,
+ accepted ? "close_requested" : "close_failed",
+ accepted ? "Air APP close requested." : "Failed to request Air APP close.",
+ instance));
+ }
+ }
+
+ public Task GetInstancesAsync()
+ {
+ lock (_gate)
+ {
+ CleanupExitedInstances();
+ return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray());
+ }
+ }
+
+ public Task RegisterAsync(AirAppRegistrationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ lock (_gate)
+ {
+ var instanceKey = string.IsNullOrWhiteSpace(request.InstanceKey)
+ ? AirAppInstanceKey.Build(request.AppId, request.SourceComponentId, request.SourcePlacementId)
+ : request.InstanceKey.Trim();
+ var instance = new ManagedAirAppInstance(
+ instanceKey,
+ Normalize(request.AppId, "unknown"),
+ Normalize(request.SessionId, Guid.NewGuid().ToString("N")),
+ request.ProcessId,
+ Normalize(request.WindowTitle, $"{request.AppId} - Air APP"),
+ request.SourceComponentId,
+ request.SourcePlacementId);
+ _instances[instanceKey] = instance;
+ Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
+ return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
+ }
+ }
+
+ public Task UnregisterAsync(string instanceKey, int processId)
+ {
+ lock (_gate)
+ {
+ if (_instances.TryGetValue(instanceKey, out var instance) &&
+ (processId <= 0 || instance.ProcessId == processId))
+ {
+ _instances.Remove(instanceKey);
+ Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
+ return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
+ }
+
+ return Task.FromResult(BuildResult(false, "not_found", "Air APP instance was not found.", null));
+ }
+ }
+
+ public bool HasLiveAirApps()
+ {
+ lock (_gate)
+ {
+ CleanupExitedInstances();
+ return _instances.Values.Any(static instance => IsProcessAlive(instance.ProcessId));
+ }
+ }
+
+ private void CleanupExitedInstances()
+ {
+ var exitedKeys = _instances
+ .Where(static pair => !IsProcessAlive(pair.Value.ProcessId))
+ .Select(static pair => pair.Key)
+ .ToList();
+
+ foreach (var key in exitedKeys)
+ {
+ _instances.Remove(key);
+ Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
+ }
+ }
+
+ private static AirAppOperationResult BuildResult(
+ bool accepted,
+ string code,
+ string message,
+ ManagedAirAppInstance? instance)
+ {
+ return new AirAppOperationResult(accepted, code, message, instance?.ToInfo());
+ }
+
+ private static bool TryActivateProcess(int processId)
+ {
+ try
+ {
+ using var process = Process.GetProcessById(processId);
+ if (process.HasExited)
+ {
+ return false;
+ }
+
+ if (!OperatingSystem.IsWindows())
+ {
+ return true;
+ }
+
+ process.Refresh();
+ var handle = process.MainWindowHandle;
+ if (handle == IntPtr.Zero)
+ {
+ return true;
+ }
+
+ _ = ShowWindow(handle, SW_SHOWNORMAL);
+ _ = SetForegroundWindow(handle);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryCloseProcess(int processId)
+ {
+ try
+ {
+ using var process = Process.GetProcessById(processId);
+ if (process.HasExited)
+ {
+ return false;
+ }
+
+ return process.CloseMainWindow();
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsProcessAlive(int processId)
+ {
+ if (processId <= 0)
+ {
+ return false;
+ }
+
+ try
+ {
+ using var process = Process.GetProcessById(processId);
+ return !process.HasExited;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string Normalize(string? value, string fallback)
+ {
+ return string.IsNullOrWhiteSpace(value)
+ ? fallback
+ : value.Trim();
+ }
+
+ private const int SW_SHOWNORMAL = 1;
+
+ [DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
+
+ private sealed class ManagedAirAppInstance
+ {
+ private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
+
+ public ManagedAirAppInstance(
+ string instanceKey,
+ string appId,
+ string sessionId,
+ int processId,
+ string windowTitle,
+ string? sourceComponentId,
+ string? sourcePlacementId)
+ {
+ InstanceKey = instanceKey;
+ AppId = appId;
+ SessionId = sessionId;
+ ProcessId = processId;
+ WindowTitle = windowTitle;
+ SourceComponentId = sourceComponentId;
+ SourcePlacementId = sourcePlacementId;
+ UpdatedAtUtc = _startedAtUtc;
+ }
+
+ public string InstanceKey { get; }
+
+ public string AppId { get; }
+
+ public string SessionId { get; }
+
+ public int ProcessId { get; }
+
+ public string WindowTitle { get; }
+
+ public string? SourceComponentId { get; }
+
+ public string? SourcePlacementId { get; }
+
+ public DateTimeOffset UpdatedAtUtc { get; private set; }
+
+ public void Touch()
+ {
+ UpdatedAtUtc = DateTimeOffset.UtcNow;
+ }
+
+ public AirAppInstanceInfo ToInfo()
+ {
+ return new AirAppInstanceInfo(
+ InstanceKey,
+ AppId,
+ SessionId,
+ ProcessId,
+ WindowTitle,
+ SourceComponentId,
+ SourcePlacementId,
+ IsProcessAlive(ProcessId),
+ _startedAtUtc,
+ UpdatedAtUtc);
+ }
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
index 7ba3430..0ef085c 100644
--- a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
+++ b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
@@ -503,7 +503,11 @@ internal sealed class DeploymentLocator
{
try
{
- var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
+ var snapshotFiles = Directory
+ .GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly)
+ .OrderByDescending(File.GetCreationTimeUtc)
+ .Take(Math.Max(1, minVersionsToKeep))
+ .ToArray();
foreach (var snapshotFile in snapshotFiles)
{
try
diff --git a/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs b/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs
new file mode 100644
index 0000000..8c69ba5
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/HostAppSettingsOobeMerger.cs
@@ -0,0 +1,184 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using LanMountainDesktop.Shared.Contracts.Launcher;
+
+namespace LanMountainDesktop.Launcher.Services;
+
+///
+/// 在 OOBE 中向 Host 的 settings.json 写入启动与展示相关字段,属性名与 Host
+/// AppSettingsSnapshot 的 JSON 序列化一致(PascalCase)。
+///
+public static class HostAppSettingsOobeMerger
+{
+ public const string ShowInTaskbarKey = "ShowInTaskbar";
+ public const string EnableFadeTransitionKey = "EnableFadeTransition";
+ public const string EnableSlideTransitionKey = "EnableSlideTransition";
+ 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");
+
+ public static HostAppSettingsStartupDefaults LoadStartupDefaults(string settingsPath)
+ {
+ if (!File.Exists(settingsPath))
+ {
+ return HostAppSettingsStartupDefaults.Fallback;
+ }
+
+ try
+ {
+ var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject();
+ if (root is null)
+ {
+ return HostAppSettingsStartupDefaults.Fallback;
+ }
+
+ var fade = ReadBool(root, EnableFadeTransitionKey, defaultValue: true);
+ var slide = ReadBool(root, EnableSlideTransitionKey, defaultValue: false);
+ var normalized = StartupVisualPreferencesResolver.FromFlags(fade, slide);
+
+ return new HostAppSettingsStartupDefaults(
+ ShowInTaskbar: ReadBool(root, ShowInTaskbarKey, defaultValue: false),
+ EnableFadeTransition: normalized.EnableFadeTransition,
+ EnableSlideTransition: normalized.EnableSlideTransition,
+ FusedPopupExperience: ReadBool(root, EnableFusedDesktopKey, defaultValue: false) &&
+ ReadBool(root, EnableThreeFingerSwipeKey, defaultValue: false),
+ AutoStartWithWindows: ReadBool(root, AutoStartWithWindowsKey, defaultValue: false));
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"HostAppSettingsOobeMerger: failed to read '{settingsPath}'. {ex.Message}");
+ return HostAppSettingsStartupDefaults.Fallback;
+ }
+ }
+
+ 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);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ JsonObject root;
+ if (File.Exists(settingsPath))
+ {
+ try
+ {
+ root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject() ?? new JsonObject();
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"HostAppSettingsOobeMerger: replacing invalid JSON at '{settingsPath}'. {ex.Message}");
+ root = new JsonObject();
+ }
+ }
+ else
+ {
+ root = new JsonObject();
+ }
+
+ var normalized = StartupVisualPreferencesResolver.FromFlags(
+ choices.EnableFadeTransition,
+ choices.EnableSlideTransition);
+
+ root[ShowInTaskbarKey] = choices.ShowInTaskbar;
+ root[EnableFadeTransitionKey] = normalized.EnableFadeTransition;
+ root[EnableSlideTransitionKey] = normalized.EnableSlideTransition;
+ root[EnableFusedDesktopKey] = choices.FusedPopupExperience;
+ root[EnableThreeFingerSwipeKey] = choices.FusedPopupExperience;
+ root[AutoStartWithWindowsKey] = choices.AutoStartWithWindows;
+
+ var options = new JsonSerializerOptions { WriteIndented = true };
+ File.WriteAllText(settingsPath, root.ToJsonString(options));
+ }
+
+ private static bool ReadBool(JsonObject root, string key, bool defaultValue)
+ {
+ if (!root.TryGetPropertyValue(key, out var node) || node is null)
+ {
+ return defaultValue;
+ }
+
+ return node switch
+ {
+ JsonValue v when v.TryGetValue(out var b) => b,
+ JsonValue v when v.TryGetValue(out var s) => bool.TryParse(s, out var p) && p,
+ _ => defaultValue
+ };
+ }
+
+ private static MultiInstanceLaunchBehavior ReadMultiInstanceLaunchBehavior(JsonObject root)
+ {
+ if (!root.TryGetPropertyValue(MultiInstanceLaunchBehaviorKey, out var node) || node is null)
+ {
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+
+ if (node is JsonValue value)
+ {
+ if (value.TryGetValue(out var text) &&
+ Enum.TryParse(text, ignoreCase: true, out var parsed))
+ {
+ return parsed;
+ }
+
+ if (value.TryGetValue(out var numeric) &&
+ Enum.IsDefined(typeof(MultiInstanceLaunchBehavior), numeric))
+ {
+ return (MultiInstanceLaunchBehavior)numeric;
+ }
+ }
+
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+}
+
+public readonly record struct HostAppSettingsStartupDefaults(
+ bool ShowInTaskbar,
+ bool EnableFadeTransition,
+ bool EnableSlideTransition,
+ bool FusedPopupExperience,
+ bool AutoStartWithWindows)
+{
+ public static HostAppSettingsStartupDefaults Fallback { get; } = new(
+ ShowInTaskbar: false,
+ EnableFadeTransition: true,
+ EnableSlideTransition: false,
+ FusedPopupExperience: false,
+ AutoStartWithWindows: false);
+}
+
+public readonly record struct HostAppSettingsStartupChoices(
+ bool ShowInTaskbar,
+ bool EnableFadeTransition,
+ bool EnableSlideTransition,
+ bool FusedPopupExperience,
+ bool AutoStartWithWindows);
diff --git a/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcServer.cs b/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcServer.cs
index 4a2c08f..7e8b044 100644
--- a/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcServer.cs
+++ b/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcServer.cs
@@ -10,6 +10,9 @@ internal sealed class LauncherCoordinatorIpcServer : IDisposable
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
+ private const int BackoffBaseMs = 250;
+ private const int BackoffMaxMs = 8000;
+ private const int BackoffJitterMs = 150;
private readonly string _pipeName;
private readonly Func> _requestHandler;
private readonly Action _heartbeatHandler;
@@ -78,6 +81,8 @@ internal sealed class LauncherCoordinatorIpcServer : IDisposable
private async Task ListenLoopAsync()
{
+ var consecutiveErrors = 0;
+
while (!_cts.IsCancellationRequested)
{
NamedPipeServerStream? server = null;
@@ -94,6 +99,7 @@ internal sealed class LauncherCoordinatorIpcServer : IDisposable
var connectedServer = server;
_ = Task.Run(() => HandleConnectionAsync(connectedServer, _cts.Token), _cts.Token);
server = null;
+ consecutiveErrors = 0;
}
catch (OperationCanceledException)
{
@@ -101,10 +107,12 @@ internal sealed class LauncherCoordinatorIpcServer : IDisposable
}
catch (Exception ex)
{
- Logger.Warn($"Launcher coordinator IPC listener failed: {ex.Message}");
+ consecutiveErrors++;
+ var delay = ComputeBackoff(consecutiveErrors);
+ Logger.Warn($"Launcher coordinator IPC listener failed (attempt {consecutiveErrors}), retrying in {delay}ms: {ex.Message}");
try
{
- await Task.Delay(250, _cts.Token).ConfigureAwait(false);
+ await Task.Delay(delay, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -118,6 +126,14 @@ internal sealed class LauncherCoordinatorIpcServer : IDisposable
}
}
+ private int ComputeBackoff(int attempt)
+ {
+ var exponential = BackoffBaseMs * (1 << Math.Min(attempt - 1, 5));
+ var capped = Math.Min(exponential, BackoffMaxMs);
+ var jitter = Random.Shared.Next(0, BackoffJitterMs);
+ return capped + jitter;
+ }
+
private async Task HeartbeatLoopAsync()
{
while (!_cts.IsCancellationRequested)
diff --git a/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs b/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
deleted file mode 100644
index ddc5d8a..0000000
--- a/LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
+++ /dev/null
@@ -1,192 +0,0 @@
-using System.Buffers;
-using System.IO.Pipes;
-using System.Text.Json;
-using LanMountainDesktop.Shared.Contracts.Launcher;
-
-namespace LanMountainDesktop.Launcher.Services.Ipc;
-
-///
-/// Launcher IPC 服务端 - 接收主程序的启动进度报告
-/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
-/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
-///
-public class LauncherIpcServer : IDisposable
-{
- private readonly CancellationTokenSource _cts = new();
- private readonly Action _onProgress;
- private Task? _listenTask;
- private NamedPipeServerStream? _currentPipe;
-
- ///
- /// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
- /// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
- ///
- private const int LengthPrefixSize = 4;
-
- public LauncherIpcServer(Action onProgress)
- {
- _onProgress = onProgress;
- }
-
- ///
- /// 启动 IPC 服务端监听
- ///
- public void Start()
- {
- _listenTask = Task.Run(ListenLoopAsync, _cts.Token);
- }
-
- private async Task ListenLoopAsync()
- {
- while (!_cts.Token.IsCancellationRequested)
- {
- NamedPipeServerStream? pipe = null;
- try
- {
- pipe = new NamedPipeServerStream(
- LauncherIpcConstants.PipeName,
- PipeDirection.In,
- 1,
- PipeTransmissionMode.Byte);
-
- _currentPipe = pipe;
- await pipe.WaitForConnectionAsync(_cts.Token);
-
- // 持久连接:在同一连接上循环读取多条消息,直到客户端断开
- await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (IOException)
- {
- // 客户端断开连接,继续等待新连接
- continue;
- }
- catch (ObjectDisposedException)
- {
- break;
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"IPC listen error: {ex.Message}");
- try
- {
- await Task.Delay(200, _cts.Token);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- }
- finally
- {
- try
- {
- pipe?.Dispose();
- }
- catch { }
-
- if (ReferenceEquals(_currentPipe, pipe))
- {
- _currentPipe = null;
- }
- }
- }
- }
-
- ///
- /// 从已连接的管道中持续读取消息,直到连接断开或取消
- ///
- private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
- {
- var lengthBuffer = ArrayPool.Shared.Rent(LengthPrefixSize);
- try
- {
- while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
- {
- // 1. 读取 4 字节长度前缀
- var totalRead = 0;
- while (totalRead < LengthPrefixSize)
- {
- var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
- if (read == 0)
- {
- // 连接已关闭
- return;
- }
- totalRead += read;
- }
-
- var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
- if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
- {
- // 无效长度,跳过此连接
- return;
- }
-
- // 2. 读取消息正文
- var payloadBuffer = ArrayPool.Shared.Rent(payloadLength);
- try
- {
- totalRead = 0;
- while (totalRead < payloadLength)
- {
- var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
- if (read == 0)
- {
- return;
- }
- totalRead += read;
- }
-
- // 3. 反序列化并回调
- var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
- var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
- if (message is not null)
- {
- _onProgress(message);
- }
- }
- catch (JsonException)
- {
- // 忽略解析错误,继续读取下一条消息
- }
- finally
- {
- ArrayPool.Shared.Return(payloadBuffer);
- }
- }
- }
- finally
- {
- ArrayPool.Shared.Return(lengthBuffer);
- }
- }
-
- ///
- /// 停止 IPC 服务端
- ///
- public void Stop()
- {
- _cts.Cancel();
- try
- {
- _currentPipe?.Dispose();
- }
- catch { }
- }
-
- public void Dispose()
- {
- Stop();
- _cts.Dispose();
-
- try
- {
- _listenTask?.Wait(TimeSpan.FromSeconds(2));
- }
- catch { }
- }
-}
diff --git a/LanMountainDesktop.Launcher/Services/LanguagePreferenceService.cs b/LanMountainDesktop.Launcher/Services/LanguagePreferenceService.cs
new file mode 100644
index 0000000..561eaec
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/LanguagePreferenceService.cs
@@ -0,0 +1,56 @@
+using System.Globalization;
+using System.Text.Json.Nodes;
+
+namespace LanMountainDesktop.Launcher.Services;
+
+internal static class LanguagePreferenceService
+{
+ public static string ResolveLanguageCode(string appRoot)
+ {
+ try
+ {
+ var dataLocationResolver = new DataLocationResolver(appRoot);
+ var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataLocationResolver.ResolveDataRoot());
+ if (!File.Exists(settingsPath))
+ {
+ return "zh-CN";
+ }
+
+ var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject();
+ if (root is not null &&
+ root.TryGetPropertyValue("LanguageCode", out var node) &&
+ node is JsonValue value &&
+ value.TryGetValue(out var code) &&
+ !string.IsNullOrWhiteSpace(code))
+ {
+ return NormalizeLanguageCode(code);
+ }
+ }
+ catch
+ {
+ }
+
+ return "zh-CN";
+ }
+
+ public static void ApplyLanguage(string languageCode)
+ {
+ var normalized = NormalizeLanguageCode(languageCode);
+ var culture = CultureInfo.GetCultureInfo(normalized);
+ CultureInfo.DefaultThreadCurrentCulture = culture;
+ CultureInfo.DefaultThreadCurrentUICulture = culture;
+ Thread.CurrentThread.CurrentCulture = culture;
+ Thread.CurrentThread.CurrentUICulture = culture;
+ }
+
+ private static string NormalizeLanguageCode(string code)
+ {
+ return code.ToLowerInvariant() switch
+ {
+ "en-us" or "en" => "en-US",
+ "ja-jp" or "ja" => "ja-JP",
+ "ko-kr" or "ko" => "ko-KR",
+ _ => "zh-CN"
+ };
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
index c598042..265db07 100644
--- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
+++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
+using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -13,8 +14,8 @@ internal sealed class LauncherFlowCoordinator
{
private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(30);
- private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。";
- private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。";
+ private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage;
+ private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage;
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
@@ -218,56 +219,53 @@ internal sealed class LauncherFlowCoordinator
{
if (ShouldProbeExistingHostBeforeLaunch(_context))
{
- var existingActivation = await TryActivateExistingHostWithStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
+ var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior();
+ var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
.ConfigureAwait(false);
- if (existingActivation is not null)
+ if (IsExistingHostReadyForLauncherDecision(existingShellStatus))
{
ipcConnected = true;
- shellStatus = existingActivation.Status;
- var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
- lastStage = existingActivation.Accepted
+ shellStatus = existingShellStatus;
+ var decisionResult = await ApplyExistingHostBehaviorAsync(
+ ipcClient,
+ multiInstanceBehavior,
+ existingShellStatus!)
+ .ConfigureAwait(false);
+ shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
+ var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
+ IsRecoverableActivationFailure(decisionResult.ActivationResult);
+ lastStage = decisionResult.Success || recoverableActivationFailure
? StartupStage.ActivationRedirected
: StartupStage.ActivationFailed;
- lastStageMessage = existingActivation.Message;
- if (existingActivation.Accepted)
+ lastStageMessage = decisionResult.Message;
+ if (decisionResult.Success || recoverableActivationFailure)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
}
- else if (recoverableActivationFailure)
- {
- _startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
- }
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
}
- PublishCoordinatorStatus(
- hostProcessAliveOverride: true,
- completed: true,
- succeeded: existingActivation.Accepted || recoverableActivationFailure);
+ PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: decisionResult.Success);
windowsClosingByCoordinator = true;
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
- success: existingActivation.Accepted || recoverableActivationFailure,
+ success: decisionResult.Success,
stage: "launch",
- code: existingActivation.Accepted
- ? "existing_host_activated"
- : recoverableActivationFailure
- ? "existing_host_startup_pending"
- : "existing_host_activation_failed",
- message: recoverableActivationFailure
- ? "Existing desktop process is still starting; Launcher will not start another process."
- : existingActivation.Message,
+ code: decisionResult.Code,
+ message: decisionResult.Message,
details: MergeDetails(
launcherContextDetails,
new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["publicIpcConnected"] = "true",
- ["existingHostPid"] = existingActivation.Status.ProcessId.ToString(),
- ["existingShellState"] = existingActivation.Status.ShellState,
- ["existingTrayState"] = existingActivation.Status.Tray.State,
- ["existingTaskbarUsable"] = existingActivation.Status.Taskbar.IsUsable.ToString()
+ ["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
+ ["existingHostPid"] = shellStatus?.ProcessId.ToString() ?? string.Empty,
+ ["existingShellState"] = shellStatus?.ShellState ?? string.Empty,
+ ["existingTrayState"] = shellStatus?.Tray.State ?? string.Empty,
+ ["existingTaskbarUsable"] = shellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
+ ["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
}));
}
}
@@ -492,7 +490,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 +555,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 +564,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 +583,7 @@ internal sealed class LauncherFlowCoordinator
}
if (!activationRetryAttempted &&
- exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
+ IsFailedActivationExitCode(exitCode))
{
activationRetryAttempted = true;
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
@@ -633,30 +608,7 @@ internal sealed class LauncherFlowCoordinator
}));
}
- var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
- if (retryOutcome is not null)
- {
- if (retryOutcome.Success)
- {
- _startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
- PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
- }
- else
- {
- _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
- PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
- }
-
- await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
- return WithAdditionalDetails(
- retryOutcome,
- MergeDetails(
- ComposeLaunchDetails(hostProcessAlive: false, recoveryActivationAttempted: true),
- new Dictionary(StringComparer.OrdinalIgnoreCase)
- {
- ["exitCode"] = exitCode.ToString()
- }));
- }
+ Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host.");
}
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
@@ -665,10 +617,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 +861,6 @@ internal sealed class LauncherFlowCoordinator
}
}
- private async Task RetryActivationAfterEarlyFailureAsync()
- {
- Logger.Warn("Attempting one explicit activation retry after host early failure.");
- var retryOutcome = await LaunchHostWithIpcAsync(forceDirectMode: true, retryTag: "explicit-activation-retry").ConfigureAwait(false);
- if (!retryOutcome.Result.Success)
- {
- return retryOutcome.Result;
- }
-
- if (retryOutcome.ImmediateResult is not null)
- {
- return retryOutcome.ImmediateResult;
- }
-
- if (retryOutcome.Process is not null)
- {
- var retryExitTask = retryOutcome.Process.WaitForExitAsync();
- var completed = await Task.WhenAny(retryExitTask, Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
-
- if (completed != retryExitTask)
- {
- return BuildResult(
- success: true,
- stage: "launch",
- code: "activation_retry_started",
- message: "Activation retry started the host successfully.",
- details: retryOutcome.Details);
- }
-
- if (retryOutcome.Process.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
- {
- return BuildResult(
- success: true,
- stage: "launch",
- code: "activation_redirected",
- message: "Activation retry redirected to the existing desktop instance.",
- details: retryOutcome.Details);
- }
- }
-
- return BuildResult(
- success: false,
- stage: "launch",
- code: "activation_failed",
- message: "Activation retry failed to make the desktop visible.",
- details: retryOutcome.Details);
- }
-
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
try
@@ -1087,7 +991,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 +1005,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 +1015,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 +1373,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 +1393,169 @@ internal sealed class LauncherFlowCoordinator
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
}
+ private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior()
+ {
+ try
+ {
+ var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot());
+ return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
+ return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+ }
+ }
+
+ internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status)
+ {
+ return status is { PublicIpcReady: true, ProcessId: > 0 };
+ }
+
+ private static async Task TryGetExistingHostStatusAsync(
+ LanMountainDesktopIpcClient ipcClient,
+ TimeSpan timeout)
+ {
+ try
+ {
+ var connected = ipcClient.IsConnected ||
+ await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
+ if (!connected)
+ {
+ return null;
+ }
+
+ var shellProxy = ipcClient.CreateProxy();
+ return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Info($"Existing host status probe did not complete: {ex.Message}");
+ return null;
+ }
+ }
+
+ private static async Task ApplyExistingHostBehaviorAsync(
+ LanMountainDesktopIpcClient ipcClient,
+ MultiInstanceLaunchBehavior behavior,
+ PublicShellStatus status)
+ {
+ try
+ {
+ var shellProxy = ipcClient.CreateProxy();
+ return behavior switch
+ {
+ MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: false,
+ successCode: "existing_host_activated",
+ successMessage: "Launcher activated the existing desktop instance.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false),
+
+ MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: true,
+ successCode: "existing_host_activated_with_notice",
+ successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false),
+
+ MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
+ shellProxy,
+ status).ConfigureAwait(false),
+
+ MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
+
+ _ => await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: true,
+ successCode: "existing_host_activated_with_notice",
+ successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false)
+ };
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
+ return new ExistingHostBehaviorResult(
+ false,
+ "multi_instance_behavior_failed",
+ $"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
+ null);
+ }
+ }
+
+ private static async Task ActivateExistingHostForBehaviorAsync(
+ IPublicShellControlService shellProxy,
+ bool showLauncherNotice,
+ string successCode,
+ string successMessage,
+ string failureCode)
+ {
+ var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
+ var success = activation.Accepted || IsRecoverableActivationFailure(activation);
+ if (showLauncherNotice && success)
+ {
+ var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
+ if (promptResult == MultiInstancePromptResult.OpenDesktop)
+ {
+ activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
+ }
+ }
+
+ return new ExistingHostBehaviorResult(
+ success,
+ activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
+ activation.Accepted ? successMessage : activation.Message,
+ activation);
+ }
+
+ private static async Task RestartExistingHostAsync(
+ IPublicShellControlService shellProxy)
+ {
+ var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
+ return new ExistingHostBehaviorResult(
+ accepted,
+ accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
+ accepted
+ ? "Launcher requested the existing desktop instance to restart."
+ : "Launcher could not request restart from the existing desktop instance.",
+ null);
+ }
+
+ private static async Task ShowPromptOnlyExistingHostAsync(
+ IPublicShellControlService shellProxy,
+ PublicShellStatus status)
+ {
+ var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
+
+ if (promptResult == MultiInstancePromptResult.OpenDesktop)
+ {
+ return await ActivateExistingHostForBehaviorAsync(
+ shellProxy,
+ showLauncherNotice: false,
+ successCode: "existing_host_activated_from_prompt",
+ successMessage: "Launcher activated the existing desktop instance from the prompt.",
+ failureCode: "existing_host_activation_failed").ConfigureAwait(false);
+ }
+
+ return new ExistingHostBehaviorResult(
+ true,
+ "existing_host_prompt_only",
+ "Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
+ null);
+ }
+
+ private static async Task ShowMultiInstancePromptAsync(PublicShellStatus status)
+ {
+ return await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var prompt = new MultiInstancePromptWindow();
+ prompt.SetDetails(status.ProcessId, status.ShellState);
+ prompt.Show();
+ return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
+ });
+ }
+
private static async Task TryActivateExistingHostWithStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
@@ -1507,7 +1574,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 +1608,7 @@ internal sealed class LauncherFlowCoordinator
: null;
}
- private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
+ internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
{
if (activation.Accepted)
{
@@ -1560,6 +1627,12 @@ internal sealed class LauncherFlowCoordinator
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
}
+ internal static bool IsSuccessfulActivationExitCode(int exitCode) =>
+ exitCode == HostExitCodes.SecondaryActivationSucceeded;
+
+ internal static bool IsFailedActivationExitCode(int exitCode) =>
+ exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired;
+
private static async Task TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{
@@ -1759,6 +1832,12 @@ internal sealed class LauncherFlowCoordinator
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
}
+ private sealed record ExistingHostBehaviorResult(
+ bool Success,
+ string Code,
+ string Message,
+ PublicShellActivationResult? ActivationResult);
+
private sealed record HostLaunchOutcome(
LauncherResult Result,
Process? Process,
diff --git a/LanMountainDesktop.Launcher/Services/LauncherWindowsStartupService.cs b/LanMountainDesktop.Launcher/Services/LauncherWindowsStartupService.cs
new file mode 100644
index 0000000..d94a912
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Services/LauncherWindowsStartupService.cs
@@ -0,0 +1,82 @@
+using System;
+using Microsoft.Win32;
+
+namespace LanMountainDesktop.Launcher.Services;
+
+///
+/// 将当前 Windows 用户登录时自启动项指向本 Launcher 进程(与正式入口一致)。
+/// Host 内 WindowsStartupService 使用 Host 进程路径;
+/// OOBE 在 Launcher 内执行时应使用本类型,以便开机后仍走更新/版本协调流程。
+///
+public sealed class LauncherWindowsStartupService
+{
+ private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
+ private const string ValueName = "LanMountainDesktop";
+ private readonly string _startupCommand;
+
+ public LauncherWindowsStartupService()
+ {
+ var processPath = Environment.ProcessPath;
+ _startupCommand = string.IsNullOrWhiteSpace(processPath)
+ ? string.Empty
+ : $"\"{processPath}\"";
+ }
+
+ public bool IsEnabled()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ try
+ {
+ using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
+ return runKey?.GetValue(ValueName) is string value &&
+ !string.IsNullOrWhiteSpace(value);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"LauncherWindowsStartup: failed to read Run key. {ex.Message}");
+ return false;
+ }
+ }
+
+ public bool SetEnabled(bool enabled)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ if (enabled && string.IsNullOrWhiteSpace(_startupCommand))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var runKey = Registry.CurrentUser.CreateSubKey(RunKeyPath);
+ if (runKey is null)
+ {
+ return false;
+ }
+
+ if (enabled)
+ {
+ runKey.SetValue(ValueName, _startupCommand, RegistryValueKind.String);
+ }
+ else
+ {
+ runKey.DeleteValue(ValueName, throwOnMissingValue: false);
+ }
+
+ return IsEnabled() == enabled;
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"LauncherWindowsStartup: failed to set Run key. Enabled={enabled}. {ex.Message}");
+ return false;
+ }
+ }
+}
diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
index 67cdb1c..312bdb6 100644
--- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
+++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
@@ -26,6 +26,7 @@ internal sealed class UpdateEngineService
private readonly string _launcherRoot;
private readonly string _incomingRoot;
private readonly string _snapshotsRoot;
+ private readonly string _installCheckpointPath;
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
{
@@ -36,6 +37,7 @@ internal sealed class UpdateEngineService
_launcherRoot = resolver.ResolveLauncherDataPath();
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
+ _installCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(_appRoot);
}
public LauncherResult CheckPendingUpdate()
@@ -129,19 +131,274 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(_incomingRoot);
Directory.CreateDirectory(_snapshotsRoot);
- var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName);
- var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName);
- var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName);
- if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
+ var stateValidation = ValidateIncomingState();
+ if (!stateValidation.Success)
{
- return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath);
+ return stateValidation;
}
- var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
- var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
- var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
+ var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot);
+ try
+ {
+ File.WriteAllText(applyLockPath, DateTimeOffset.UtcNow.ToString("O"));
+ }
+ catch (Exception ex)
+ {
+ return Failed("update.apply", "lock_conflict", $"Failed to acquire apply lock: {ex.Message}");
+ }
- if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
+ try
+ {
+ var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName);
+ var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName);
+ var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName);
+ if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
+ {
+ return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath);
+ }
+
+ var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
+ var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
+ var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
+
+ if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
+ {
+ return new LauncherResult
+ {
+ Success = true,
+ Stage = "update.apply",
+ Code = "noop",
+ Message = "No update payload found."
+ };
+ }
+
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0));
+ var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName);
+ if (!verifyResult.Success)
+ {
+ _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
+ return Failed("update.apply", "signature_failed", verifyResult.Message);
+ }
+
+ var fileMapText = await File.ReadAllTextAsync(fileMapPath);
+ var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
+ if (fileMap is null || fileMap.Files.Count == 0)
+ {
+ _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false));
+ return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
+ }
+
+ var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
+ if (string.IsNullOrWhiteSpace(currentDeployment))
+ {
+ // Initial install path: no current deployment exists, so apply the staged package directly.
+ }
+
+ var currentVersion = _deploymentLocator.GetCurrentVersion();
+ if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
+ !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ return Failed(
+ "update.apply",
+ "version_mismatch",
+ $"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
+ }
+
+ var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
+ var existingCheckpoint = LoadInstallCheckpoint();
+ var canResume = existingCheckpoint is not null
+ && string.Equals(existingCheckpoint.SourceVersion, currentVersion, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase)
+ && Directory.Exists(existingCheckpoint.TargetDirectory)
+ && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial"));
+
+ if (existingCheckpoint is not null && !canResume)
+ {
+ return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload.");
+ }
+
+ var targetDeployment = canResume
+ ? existingCheckpoint!.TargetDirectory
+ : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
+ var partialMarker = Path.Combine(targetDeployment, ".partial");
+ var snapshot = new SnapshotMetadata
+ {
+ SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
+ SourceVersion = currentVersion,
+ TargetVersion = targetVersion,
+ CreatedAt = DateTimeOffset.UtcNow,
+ SourceDirectory = currentDeployment,
+ TargetDirectory = targetDeployment,
+ Status = "pending"
+ };
+ var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
+ var checkpoint = canResume
+ ? existingCheckpoint!
+ : new InstallCheckpoint
+ {
+ SnapshotId = snapshot.SnapshotId,
+ SourceVersion = currentVersion,
+ TargetVersion = targetVersion,
+ SourceDirectory = currentDeployment,
+ TargetDirectory = targetDeployment,
+ IsInitialDeployment = false,
+ AppliedCount = 0,
+ VerifiedCount = 0
+ };
+
+ var extractRoot = Path.Combine(_incomingRoot, "extracted");
+ try
+ {
+ SaveSnapshot(snapshotPath, snapshot);
+
+ if (Directory.Exists(extractRoot))
+ {
+ Directory.Delete(extractRoot, true);
+ }
+
+ Directory.CreateDirectory(extractRoot);
+ ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
+
+ if (!canResume)
+ {
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count));
+ Directory.CreateDirectory(targetDeployment);
+ File.WriteAllText(partialMarker, string.Empty);
+ }
+
+ SaveInstallCheckpoint(checkpoint);
+
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, checkpoint.AppliedCount, fileMap.Files.Count));
+ for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileMap.Files.Count; fileIndex++)
+ {
+ var file = fileMap.Files[fileIndex];
+ ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
+ checkpoint.AppliedCount = fileIndex + 1;
+ SaveInstallCheckpoint(checkpoint);
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (checkpoint.AppliedCount * 30 / fileMap.Files.Count), file.Path, checkpoint.AppliedCount, fileMap.Files.Count));
+ }
+
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, checkpoint.VerifiedCount, fileMap.Files.Count));
+ for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileMap.Files.Count; verifyIndex++)
+ {
+ var file = fileMap.Files[verifyIndex];
+ if (!NeedsVerification(file))
+ {
+ checkpoint.VerifiedCount = verifyIndex + 1;
+ SaveInstallCheckpoint(checkpoint);
+ continue;
+ }
+
+ var fullPath = Path.Combine(targetDeployment, file.Path);
+ var actualHash = ComputeSha256Hex(fullPath);
+ if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
+ }
+
+ checkpoint.VerifiedCount = verifyIndex + 1;
+ SaveInstallCheckpoint(checkpoint);
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileMap.Files.Count), file.Path, checkpoint.VerifiedCount, fileMap.Files.Count));
+ }
+
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count));
+ ActivateDeployment(currentDeployment, targetDeployment);
+
+ snapshot.Status = "applied";
+ SaveSnapshot(snapshotPath, snapshot);
+ CleanupIncomingArtifacts();
+ RetainDeploymentsForRollback();
+
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count));
+ _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false));
+
+ return new LauncherResult
+ {
+ Success = true,
+ Stage = "update.apply",
+ Code = "ok",
+ Message = $"Updated to {targetVersion}.",
+ CurrentVersion = currentVersion,
+ TargetVersion = targetVersion
+ };
+ }
+ catch (Exception ex)
+ {
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
+ var rollbackResult = TryRollbackOnFailure(snapshot);
+ snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
+ SaveSnapshot(snapshotPath, snapshot);
+ var errorMessage = rollbackResult.Success
+ ? ex.Message
+ : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
+ _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success));
+ return new LauncherResult
+ {
+ Success = false,
+ Stage = "update.apply",
+ Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
+ Message = rollbackResult.Success
+ ? "Failed to apply update. Rolled back to previous version."
+ : "Failed to apply update and rollback failed.",
+ ErrorMessage = errorMessage,
+ CurrentVersion = currentVersion,
+ RolledBackTo = rollbackResult.Success ? currentVersion : null
+ };
+ }
+ finally
+ {
+ DeleteInstallCheckpoint();
+ try
+ {
+ if (Directory.Exists(extractRoot))
+ {
+ Directory.Delete(extractRoot, true);
+ }
+ }
+ catch
+ {
+ }
+ }
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(applyLockPath))
+ {
+ File.Delete(applyLockPath);
+ }
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private LauncherResult ValidateIncomingState()
+ {
+ var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot);
+ if (File.Exists(applyLockPath))
+ {
+ return Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress.");
+ }
+
+ var deploymentLockPath = ContractsUpdate.UpdatePaths.GetDeploymentLockPath(_appRoot);
+ if (!File.Exists(deploymentLockPath))
+ {
+ return Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update.");
+ }
+
+ var markerPath = ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(_appRoot);
+ var hasPlondsMap = File.Exists(Path.Combine(_incomingRoot, PlondsFileMapName));
+ var hasLegacyMap = File.Exists(Path.Combine(_incomingRoot, SignedFileMapName));
+ if (hasPlondsMap && !File.Exists(markerPath))
+ {
+ return Failed("update.apply", "staging_incomplete", "Download marker is missing for pending PLONDS update.");
+ }
+
+ if (!hasPlondsMap && !hasLegacyMap)
{
return new LauncherResult
{
@@ -152,151 +409,13 @@ internal sealed class UpdateEngineService
};
}
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0));
- var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName);
- if (!verifyResult.Success)
+ return new LauncherResult
{
- _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
- return Failed("update.apply", "signature_failed", verifyResult.Message);
- }
-
- var fileMapText = await File.ReadAllTextAsync(fileMapPath);
- var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
- if (fileMap is null || fileMap.Files.Count == 0)
- {
- _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false));
- return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
- }
-
- var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
- if (string.IsNullOrWhiteSpace(currentDeployment))
- {
- // Initial install path: no current deployment exists, so apply the staged package directly.
- }
-
- var currentVersion = _deploymentLocator.GetCurrentVersion();
- if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
- !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
- {
- return Failed(
- "update.apply",
- "version_mismatch",
- $"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
- }
-
- var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
- var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
- var partialMarker = Path.Combine(targetDeployment, ".partial");
- var snapshot = new SnapshotMetadata
- {
- SnapshotId = Guid.NewGuid().ToString("N"),
- SourceVersion = currentVersion,
- TargetVersion = targetVersion,
- CreatedAt = DateTimeOffset.UtcNow,
- SourceDirectory = currentDeployment,
- TargetDirectory = targetDeployment,
- Status = "pending"
+ Success = true,
+ Stage = "update.apply",
+ Code = "ok",
+ Message = "Incoming update state validated."
};
- var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
-
- var extractRoot = Path.Combine(_incomingRoot, "extracted");
- try
- {
- SaveSnapshot(snapshotPath, snapshot);
-
- if (Directory.Exists(extractRoot))
- {
- Directory.Delete(extractRoot, true);
- }
-
- Directory.CreateDirectory(extractRoot);
- ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
-
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count));
- Directory.CreateDirectory(targetDeployment);
- File.WriteAllText(partialMarker, string.Empty);
-
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, 0, fileMap.Files.Count));
- var fileIndex = 0;
- foreach (var file in fileMap.Files)
- {
- ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
- fileIndex++;
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (fileIndex * 30 / fileMap.Files.Count), file.Path, fileIndex, fileMap.Files.Count));
- }
-
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, 0, fileMap.Files.Count));
- var verifyIndex = 0;
- foreach (var file in fileMap.Files)
- {
- if (!NeedsVerification(file))
- {
- continue;
- }
-
- var fullPath = Path.Combine(targetDeployment, file.Path);
- var actualHash = ComputeSha256Hex(fullPath);
- if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
- {
- throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
- }
-
- verifyIndex++;
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (verifyIndex * 15 / fileMap.Files.Count), file.Path, verifyIndex, fileMap.Files.Count));
- }
-
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count));
- ActivateDeployment(currentDeployment, targetDeployment);
-
- snapshot.Status = "applied";
- SaveSnapshot(snapshotPath, snapshot);
- CleanupIncomingArtifacts();
- CleanupDestroyedDeployments();
-
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count));
- _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false));
-
- return new LauncherResult
- {
- Success = true,
- Stage = "update.apply",
- Code = "ok",
- Message = $"Updated to {targetVersion}.",
- CurrentVersion = currentVersion,
- TargetVersion = targetVersion
- };
- }
- catch (Exception ex)
- {
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
- TryRollbackOnFailure(snapshot);
- snapshot.Status = "rolled_back";
- SaveSnapshot(snapshotPath, snapshot);
- _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, ex.Message, true));
- return new LauncherResult
- {
- Success = false,
- Stage = "update.apply",
- Code = "apply_failed",
- Message = "Failed to apply update. Rolled back to previous version.",
- ErrorMessage = ex.Message,
- CurrentVersion = currentVersion,
- RolledBackTo = currentVersion
- };
- }
- finally
- {
- try
- {
- if (Directory.Exists(extractRoot))
- {
- Directory.Delete(extractRoot, true);
- }
- }
- catch
- {
- }
- }
}
private async Task ApplyPendingPlondsUpdateAsync(
@@ -348,11 +467,26 @@ internal sealed class UpdateEngineService
}
var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment);
- var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion!);
+ var existingCheckpoint = LoadInstallCheckpoint();
+ var canResume = existingCheckpoint is not null
+ && string.Equals(existingCheckpoint.SourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase)
+ && Directory.Exists(existingCheckpoint.TargetDirectory)
+ && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial"));
+
+ if (existingCheckpoint is not null && !canResume)
+ {
+ return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload.");
+ }
+
+ var targetDeployment = canResume
+ ? existingCheckpoint!.TargetDirectory
+ : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion!);
var partialMarker = Path.Combine(targetDeployment, ".partial");
var snapshot = new SnapshotMetadata
{
- SnapshotId = Guid.NewGuid().ToString("N"),
+ SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
@@ -362,35 +496,56 @@ internal sealed class UpdateEngineService
};
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
+ var checkpoint = canResume
+ ? existingCheckpoint!
+ : new InstallCheckpoint
+ {
+ SnapshotId = snapshot.SnapshotId,
+ SourceVersion = sourceVersion,
+ TargetVersion = targetVersion,
+ SourceDirectory = currentDeployment,
+ TargetDirectory = targetDeployment,
+ IsInitialDeployment = isInitialDeployment,
+ AppliedCount = 0,
+ VerifiedCount = 0
+ };
+
try
{
SaveSnapshot(snapshotPath, snapshot);
- if (Directory.Exists(targetDeployment))
+ if (!canResume)
{
- Directory.Delete(targetDeployment, true);
+ if (Directory.Exists(targetDeployment))
+ {
+ Directory.Delete(targetDeployment, true);
+ }
+
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
+ Directory.CreateDirectory(targetDeployment);
+ File.WriteAllText(partialMarker, string.Empty);
}
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
- Directory.CreateDirectory(targetDeployment);
- File.WriteAllText(partialMarker, string.Empty);
+ SaveInstallCheckpoint(checkpoint);
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, 0, fileEntries.Count));
- var fileIndex = 0;
- foreach (var entry in fileEntries)
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count));
+ for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++)
{
+ var entry = fileEntries[fileIndex];
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
- fileIndex++;
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (fileIndex * 30 / fileEntries.Count), entry.Path, fileIndex, fileEntries.Count));
+ checkpoint.AppliedCount = fileIndex + 1;
+ SaveInstallCheckpoint(checkpoint);
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count));
}
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, 0, fileEntries.Count));
- var verifyIndex = 0;
- foreach (var entry in fileEntries)
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count));
+ for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++)
{
+ var entry = fileEntries[verifyIndex];
VerifyPlondsFileEntry(entry, targetDeployment);
- verifyIndex++;
- _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (verifyIndex * 15 / fileEntries.Count), entry.Path, verifyIndex, fileEntries.Count));
+ checkpoint.VerifiedCount = verifyIndex + 1;
+ SaveInstallCheckpoint(checkpoint);
+ _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count));
}
if (isInitialDeployment)
@@ -410,7 +565,7 @@ internal sealed class UpdateEngineService
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
- CleanupDestroyedDeployments();
+ RetainDeploymentsForRollback();
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
@@ -456,21 +611,30 @@ internal sealed class UpdateEngineService
}
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
- TryRollbackOnFailure(snapshot);
- snapshot.Status = "rolled_back";
+ var rollbackResult = TryRollbackOnFailure(snapshot);
+ snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
SaveSnapshot(snapshotPath, snapshot);
- _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, ex.Message, true));
+ var errorMessage = rollbackResult.Success
+ ? ex.Message
+ : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}";
+ _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success));
return new LauncherResult
{
Success = false,
Stage = "update.apply",
- Code = "apply_failed",
- Message = "Failed to apply PLONDS update. Rolled back to previous version.",
- ErrorMessage = ex.Message,
+ Code = rollbackResult.Success ? "apply_failed" : "rollback_failed",
+ Message = rollbackResult.Success
+ ? "Failed to apply PLONDS update. Rolled back to previous version."
+ : "Failed to apply PLONDS update and rollback failed.",
+ ErrorMessage = errorMessage,
CurrentVersion = sourceVersion,
- RolledBackTo = sourceVersion
+ RolledBackTo = rollbackResult.Success ? sourceVersion : null
};
}
+ finally
+ {
+ DeleteInstallCheckpoint();
+ }
}
private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment)
@@ -1375,6 +1539,11 @@ internal sealed class UpdateEngineService
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
}
+ if (!Directory.Exists(snapshot.SourceDirectory))
+ {
+ return Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}");
+ }
+
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
@@ -1397,21 +1566,7 @@ internal sealed class UpdateEngineService
public void CleanupDestroyedDeployments()
{
- foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly))
- {
- if (!File.Exists(Path.Combine(dir, ".destroy")))
- {
- continue;
- }
-
- try
- {
- Directory.Delete(dir, true);
- }
- catch
- {
- }
- }
+ RetainDeploymentsForRollback();
}
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot)
@@ -1459,9 +1614,15 @@ internal sealed class UpdateEngineService
var toCurrent = Path.Combine(toDeployment, ".current");
var fromCurrent = Path.Combine(fromDeployment, ".current");
var fromDestroy = Path.Combine(fromDeployment, ".destroy");
+ var toDestroy = Path.Combine(toDeployment, ".destroy");
var toPartial = Path.Combine(toDeployment, ".partial");
File.WriteAllText(toCurrent, string.Empty);
+ if (File.Exists(toDestroy))
+ {
+ File.Delete(toDestroy);
+ }
+
if (File.Exists(fromCurrent))
{
File.Delete(fromCurrent);
@@ -1474,7 +1635,7 @@ internal sealed class UpdateEngineService
}
}
- private void TryRollbackOnFailure(SnapshotMetadata snapshot)
+ private RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot)
{
try
{
@@ -1483,6 +1644,11 @@ internal sealed class UpdateEngineService
Directory.Delete(snapshot.TargetDirectory, true);
}
+ if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory) || !Directory.Exists(snapshot.SourceDirectory))
+ {
+ return new RollbackAttemptResult(false, "Source deployment is missing.");
+ }
+
if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy")))
{
File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy"));
@@ -1492,12 +1658,22 @@ internal sealed class UpdateEngineService
{
File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty);
}
+
+ return new RollbackAttemptResult(true, null);
}
- catch
+ catch (Exception ex)
{
+ return new RollbackAttemptResult(false, ex.Message);
}
}
+ private void RetainDeploymentsForRollback()
+ {
+ _deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
+ }
+
+ private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
+
internal void CleanupIncomingArtifacts()
{
foreach (var path in new[]
@@ -1507,7 +1683,8 @@ internal sealed class UpdateEngineService
Path.Combine(_incomingRoot, ArchiveFileName),
Path.Combine(_incomingRoot, PlondsFileMapName),
Path.Combine(_incomingRoot, PlondsSignatureFileName),
- Path.Combine(_incomingRoot, PlondsUpdateMetadataName)
+ Path.Combine(_incomingRoot, PlondsUpdateMetadataName),
+ _installCheckpointPath
})
{
try
@@ -1616,6 +1793,48 @@ internal sealed class UpdateEngineService
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
}
+ private InstallCheckpoint? LoadInstallCheckpoint()
+ {
+ if (!File.Exists(_installCheckpointPath))
+ {
+ return null;
+ }
+
+ try
+ {
+ var text = File.ReadAllText(_installCheckpointPath);
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private void SaveInstallCheckpoint(InstallCheckpoint checkpoint)
+ {
+ File.WriteAllText(_installCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
+ }
+
+ private void DeleteInstallCheckpoint()
+ {
+ try
+ {
+ if (File.Exists(_installCheckpointPath))
+ {
+ File.Delete(_installCheckpointPath);
+ }
+ }
+ catch
+ {
+ }
+ }
+
private static LauncherResult Failed(string stage, string code, string message)
{
return new LauncherResult
diff --git a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
index ef012b9..b3db942 100644
--- a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
@@ -4,12 +4,14 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:fi="using:FluentIcons.Avalonia"
+ xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources"
mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="480"
x:Class="LanMountainDesktop.Launcher.Views.DataLocationPromptWindow"
x:DataType="views:DataLocationPromptWindow"
- Title="Choose Data Location"
+ x:CompileBindings="False"
+ Title="{x:Static res:Strings.DataLocation_Title}"
Width="520"
Height="480"
CanResize="False"
@@ -24,11 +26,11 @@
-
-
@@ -45,7 +47,7 @@
-
@@ -71,11 +73,11 @@
GroupName="DataLocation"
IsChecked="True" />
-
-
@@ -102,11 +104,11 @@
GroupName="DataLocation"
IsEnabled="False" />
-
-
@@ -146,7 +148,7 @@
Theme="{DynamicResource ButtonTheme}"
IsVisible="False" />
diff --git a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs
index c33383b..57b1ad7 100644
--- a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs
@@ -7,6 +7,7 @@ using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
using LanMountainDesktop.Launcher.Models;
+using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
@@ -106,7 +107,7 @@ internal partial class DataLocationPromptWindow : Window
if (migrationInfoText is not null && hasExistingData)
{
- migrationInfoText.Text = "Existing system data was detected. Choosing portable mode will migrate the current data automatically.";
+ migrationInfoText.Text = Strings.DataLocation_MigrateWarning;
}
}
diff --git a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
index e04ae05..6b083d2 100644
--- a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
@@ -3,10 +3,12 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LanMountainDesktop.Launcher.ViewModels"
+ xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="600"
x:Class="LanMountainDesktop.Launcher.Views.DevDebugWindow"
x:DataType="vm:DevDebugWindowViewModel"
- Title="开发调试窗口 - Launcher"
+ x:CompileBindings="False"
+ Title="{x:Static res:Strings.DevDebug_Title}"
Width="500"
Height="600"
WindowStartupLocation="CenterScreen"
@@ -43,7 +45,7 @@
Padding="15">
-
-
-
@@ -69,7 +71,7 @@
Padding="15">
-
-
-
@@ -95,7 +97,7 @@
Padding="15">
-
-
-
@@ -121,7 +123,7 @@
Padding="15">
-
-
-
@@ -147,7 +149,7 @@
Padding="15">
-
-
-
@@ -176,10 +178,10 @@
HorizontalAlignment="Center"
Spacing="10"
Margin="0,15">
-
-
@@ -197,7 +199,7 @@
Opacity="0.8"
TextTrimming="CharacterEllipsis" />
diff --git a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
index 01bb03a..4baf4c8 100644
--- a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
@@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
+using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.ViewModels;
using LanMountainDesktop.Launcher.Views;
@@ -62,12 +63,12 @@ public partial class DevDebugWindow : Window
{
// 查看模式:显示模拟错误
errorWindow.SetDebugMode(true);
- errorWindow.SetErrorMessage("[调试模式] 这是一个模拟的错误消息,用于查看错误页面的样式和布局。");
+ errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage);
}
else
{
// 功能模式:显示真实错误
- errorWindow.SetErrorMessage("找不到阑山桌面应用程序。\n\n请检查应用安装是否完整。");
+ errorWindow.SetErrorMessage(Strings.Error_HostNotFoundMessage);
}
errorWindow.Show();
@@ -160,7 +161,7 @@ public partial class DevDebugWindow : Window
///
private async Task SimulateSplashProgress(SplashWindow splashWindow)
{
- var stages = new[] { "初始化", "检查更新", "加载组件", "启动应用" };
+ var stages = new[] { Strings.Preview_SplashInitializing, Strings.Preview_SplashCheckingUpdates, Strings.Preview_SplashCheckingPlugins, Strings.Preview_SplashLaunchingHost };
var reporter = (ISplashStageReporter)splashWindow;
for (int i = 0; i < stages.Length; i++)
diff --git a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
index d840777..80569e9 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
@@ -3,12 +3,14 @@
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:res="clr-namespace:LanMountainDesktop.Launcher.Resources"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
x:DataType="views:ErrorDebugWindow"
- Title="调试模式"
+ x:CompileBindings="False"
+ Title="{x:Static res:Strings.DebugDebug_Title}"
Width="420"
Height="320"
CanResize="False"
@@ -23,7 +25,7 @@
-
-
+ OnContent="{x:Static res:Strings.DebugDebug_On}"
+ OffContent="{x:Static res:Strings.DebugDebug_Off}" />
@@ -58,19 +60,19 @@
Padding="16,12">
@@ -80,7 +82,7 @@
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="12,10"
IsVisible="True">
-
@@ -94,11 +96,11 @@
Spacing="12"
Margin="0,16,0,0">
diff --git a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
index db302df..6a12271 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
+using LanMountainDesktop.Launcher.Resources;
namespace LanMountainDesktop.Launcher.Views;
@@ -87,7 +88,7 @@ public partial class ErrorDebugWindow : Window
var options = new FilePickerOpenOptions
{
- Title = "Select LanMountainDesktop host executable",
+ Title = Strings.DebugDebug_SelectExeDialog,
AllowMultiple = false,
FileTypeFilter =
[
@@ -114,7 +115,7 @@ public partial class ErrorDebugWindow : Window
{
if (this.FindControl("PathTextBlock") is { } pathTextBlock)
{
- pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
+ pathTextBlock.Text = string.IsNullOrEmpty(path) ? Strings.DebugDebug_NotSelected : path;
}
}
}
diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
index 3bfb78b..d5b70f2 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
@@ -3,16 +3,22 @@
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"
+ xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow"
- Title="LanMountain Desktop"
- Width="560"
- Height="320"
+ x:CompileBindings="False"
+ Title="{x:Static res:Strings.Error_Title}"
+ Width="760"
+ Height="460"
+ MinWidth="640"
+ MinHeight="420"
CanResize="False"
WindowStartupLocation="CenterScreen"
- Background="#111318"
- TransparencyLevelHint="None"
+ Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
+ TransparencyLevelHint="Mica, AcrylicBlur, None"
Icon="/Assets/logo.ico">
@@ -20,79 +26,128 @@
-
+
+ Spacing="8">
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+ Padding="18,14"
+ Background="{DynamicResource LayerOnMicaBaseAltFillColorDefaultBrush}">
+
+
+
-
+
+
-
+
+
-
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
index a87271c..28af97e 100644
--- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
@@ -1,8 +1,11 @@
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.Resources;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
@@ -33,9 +36,21 @@ public partial class ErrorWindow : Window
public void SetErrorMessage(string message)
{
+ var normalizedMessage = string.IsNullOrWhiteSpace(message)
+ ? Strings.Error_MessageNotReached
+ : message.Trim();
+ var firstLine = normalizedMessage
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault() ?? normalizedMessage;
+
if (this.FindControl("ErrorMessageText") is { } errorText)
{
- errorText.Text = message;
+ errorText.Text = firstLine;
+ }
+
+ if (this.FindControl("ErrorDetailsTextBox") is { } detailsTextBox)
+ {
+ detailsTextBox.Text = normalizedMessage;
}
}
@@ -44,16 +59,16 @@ public partial class ErrorWindow : Window
_isDebugMode = isDebugMode;
if (isDebugMode && this.FindControl("TitleText") is { } titleText)
{
- titleText.Text = "[Debug] Launcher error";
+ titleText.Text = Strings.Error_DebugTitle;
}
}
public void ConfigureForHostNotFound()
{
ApplyActionLayout(
- title: "Launcher could not find the desktop executable",
- suggestion: "Pick another executable in debug mode, inspect logs, or retry after fixing the deployment path.",
- primaryLabel: "Retry",
+ title: Strings.Error_HostNotFoundTitle,
+ suggestion: Strings.Error_HostNotFoundMessage,
+ primaryLabel: Strings.Error_ButtonRetry,
primaryAction: ErrorWindowResult.Retry,
secondaryLabel: null,
secondaryAction: null);
@@ -62,25 +77,27 @@ public partial class ErrorWindow : Window
public void ConfigureForGenericFailure(bool allowRetry)
{
ApplyActionLayout(
- title: "Launcher could not confirm startup",
+ title: Strings.Error_TitleCannotConfirm,
suggestion: allowRetry
- ? "Inspect logs, then retry once the previous startup attempt has fully finished."
- : "Inspect logs or exit. Launcher will avoid creating another desktop process while the old one is still running.",
- primaryLabel: allowRetry ? "Retry" : "Activate",
+ ? Strings.Error_GenericRetryMessage
+ : Strings.Error_GenericNoRetryMessage,
+ primaryLabel: allowRetry ? Strings.Error_ButtonRetry : Strings.Error_ButtonActivate,
primaryAction: allowRetry ? ErrorWindowResult.Retry : ErrorWindowResult.ActivateExisting,
- secondaryLabel: allowRetry ? null : "Wait",
+ secondaryLabel: allowRetry ? null : Strings.Error_ButtonWait,
secondaryAction: allowRetry ? null : ErrorWindowResult.ContinueWaiting);
}
public void ConfigureForRunningHostFailure(int? hostPid)
{
- var pidHint = hostPid is > 0 ? $" Current host PID: {hostPid}." : string.Empty;
+ var suggestion = hostPid is > 0
+ ? string.Format(Strings.Error_PendingMessageWithPid, hostPid)
+ : Strings.Error_PendingMessage;
ApplyActionLayout(
- title: "Startup is still pending",
- suggestion: $"The desktop process is still running, so Launcher will not start a second instance.{pidHint}",
- primaryLabel: "Activate",
+ title: Strings.Error_PendingTitle,
+ suggestion: suggestion,
+ primaryLabel: Strings.Error_ButtonActivate,
primaryAction: ErrorWindowResult.ActivateExisting,
- secondaryLabel: "Wait",
+ secondaryLabel: Strings.Error_ButtonWait,
secondaryAction: ErrorWindowResult.ContinueWaiting);
}
@@ -120,6 +137,11 @@ public partial class ErrorWindow : Window
{
openLogButton.Click += OnOpenLogClick;
}
+
+ if (this.FindControl
@@ -52,10 +53,10 @@
All
-
+
@@ -79,6 +80,10 @@
+
+
+
+
@@ -102,4 +107,34 @@
+
+
+
+
+
+
+ <_AirAppHostOutput Include="..\LanMountainDesktop.AirAppHost\bin\$(Configuration)\$(TargetFramework)\**\*" />
+
+
+
+
+
+
+
+ <_AirAppHostPublishOutput Include="$(AirAppHostPublishDir)\**\*" />
+
+
+
+
diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json
index 6a79161..48f1f39 100644
--- a/LanMountainDesktop/Localization/en-US.json
+++ b/LanMountainDesktop/Localization/en-US.json
@@ -38,27 +38,27 @@
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
"settings.wallpaper.current_label": "Current Wallpaper",
- "settings.wallpaper.type_label": "Wallpaper Type",
- "settings.wallpaper.type.image": "Image",
- "settings.wallpaper.type.solid_color": "Solid Color",
- "settings.wallpaper.type.system": "System Wallpaper",
- "settings.wallpaper.system.label": "System Wallpaper",
- "settings.wallpaper.system.unavailable": "Unable to read system wallpaper",
- "settings.wallpaper.refresh_interval": "Refresh Interval",
- "settings.wallpaper.refresh_now": "Refresh Now",
- "settings.wallpaper.refresh.30s": "30 seconds",
- "settings.wallpaper.refresh.1m": "1 minute",
- "settings.wallpaper.refresh.5m": "5 minutes",
- "settings.wallpaper.refresh.10m": "10 minutes",
- "settings.wallpaper.refresh.15m": "15 minutes",
- "settings.wallpaper.refresh.30m": "30 minutes",
- "settings.wallpaper.refresh.1h": "1 hour",
- "settings.wallpaper.refresh.2h": "2 hours",
- "settings.wallpaper.refresh.4h": "4 hours",
- "settings.wallpaper.refresh.8h": "8 hours",
- "settings.wallpaper.refresh.12h": "12 hours",
- "settings.wallpaper.refresh.24h": "24 hours",
- "settings.wallpaper.color_label": "Wallpaper Color",
+"settings.wallpaper.type_label": "Wallpaper Type",
+"settings.wallpaper.type.image": "Image",
+"settings.wallpaper.type.solid_color": "Solid Color",
+"settings.wallpaper.type.system": "System Wallpaper",
+"settings.wallpaper.refresh_interval": "Refresh Interval",
+"settings.wallpaper.refresh_now": "Refresh Now",
+"settings.wallpaper.refresh.30s": "30 seconds",
+"settings.wallpaper.refresh.1m": "1 minute",
+"settings.wallpaper.refresh.5m": "5 minutes",
+"settings.wallpaper.refresh.10m": "10 minutes",
+"settings.wallpaper.refresh.15m": "15 minutes",
+"settings.wallpaper.refresh.30m": "30 minutes",
+"settings.wallpaper.refresh.1h": "1 hour",
+"settings.wallpaper.refresh.2h": "2 hours",
+"settings.wallpaper.refresh.4h": "4 hours",
+"settings.wallpaper.refresh.8h": "8 hours",
+"settings.wallpaper.refresh.12h": "12 hours",
+"settings.wallpaper.refresh.24h": "24 hours",
+"settings.wallpaper.color_label": "Wallpaper Color",
+ "settings.wallpaper.custom_color_tooltip": "Custom color",
+ "settings.wallpaper.custom_color_apply": "Apply",
"settings.wallpaper.placement_label": "Placement",
"settings.wallpaper.placement_desc": "Adjust how the image fills the desktop.",
"settings.wallpaper.pick_button": "Browse Files",
@@ -133,7 +133,7 @@
"settings.privacy.policy_hint_prefix": "For more details, please ",
"settings.privacy.view_policy": "view our privacy policy",
"settings.weather.title": "Weather",
- "settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
+ "settings.weather.description": "Configure weather location, weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source",
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
"settings.weather.mode_city_search": "City Search",
@@ -175,6 +175,19 @@
"settings.weather.settings_section": "Settings",
"settings.weather.preview_panel_header": "Weather Preview",
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
+ "settings.weather.preview_metrics_header": "Current conditions",
+ "settings.weather.preview_alerts_header": "Weather alerts",
+ "settings.weather.preview_no_alerts": "No active weather alerts.",
+ "settings.weather.metric_humidity": "Humidity",
+ "settings.weather.metric_aqi": "AQI",
+ "settings.weather.metric_wind": "Wind",
+ "settings.weather.metric_feels_like": "Feels like",
+ "settings.weather.metric_precipitation": "Precipitation",
+ "settings.weather.metric_sun": "Sunrise / sunset",
+ "settings.weather.alert_untitled": "Weather alert",
+ "settings.weather.alert_no_detail": "No details were provided.",
+ "settings.weather.alert_active": "Active alert",
+ "settings.weather.alert_published_format": "Published {0}",
"settings.weather.refresh_button": "Refresh",
"settings.weather.preview_updated_format": "Updated {0}",
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
@@ -334,6 +347,7 @@
"settings.region.language_zh": "Chinese",
"settings.region.language_en": "English",
"settings.region.language_ja": "Japanese",
+ "settings.region.language_ko": "Korean",
"settings.region.timezone_header": "Time Zone",
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
"settings.region.applied_format": "Language switched to: {0}",
@@ -346,6 +360,19 @@
"settings.general.preview_time_label": "Time",
"settings.general.preview_date_label": "Date",
"settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.",
+ "settings.general.fade_transition_header": "Fade startup transition",
+ "settings.general.slide_transition_header": "Slide startup transition",
+ "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",
"settings.appearance.description": "Adjust theme source, system material, and window chrome.",
"settings.appearance.theme_header": "Theme",
@@ -367,13 +394,18 @@
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
+ "settings.appearance.corner_radius.label": "Global corner radius style",
+ "settings.appearance.corner_radius.description": "Select a fixed corner radius style inspired by Xiaomi HyperOS.",
"component.color_scheme.follow_system": "Follow system color scheme",
"component.color_scheme.native": "Use component custom color scheme",
+ "component.settings.color_scheme": "Color Scheme",
+ "settings.appearance.system_material.auto": "Auto (recommended)",
"settings.appearance.system_material.none": "None",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
+ "settings.appearance.system_material_desc.auto": "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable.",
"settings.appearance.restart_message": "Theme source and system material changes require restarting the app.",
"settings.appearance.preview.primary": "Primary",
"settings.appearance.preview.secondary": "Secondary",
@@ -385,6 +417,45 @@
"settings.appearance.preview.apply_seed": "Apply",
"settings.appearance.preview.wallpaper_candidates": "Wallpaper seed candidates",
"settings.appearance.preview.wallpaper_current": "Current",
+ "settings.material_color.preview.wallpaper_current": "Current",
+ "settings.material_color.theme_color_mode.neutral": "Default neutral",
+ "settings.material_color.theme_color_mode.user": "User theme color Monet",
+ "settings.material_color.theme_color_mode.wallpaper": "Wallpaper Monet",
+ "settings.material_color.system_material.auto": "Auto (recommended)",
+ "settings.material_color.system_material.none": "None",
+ "settings.material_color.system_material.mica": "Mica",
+ "settings.material_color.system_material.acrylic": "Acrylic",
+ "settings.material_color.source.fallback": "Fallback",
+ "settings.material_color.role.accent": "Accent",
+ "settings.material_color.role.primary": "Primary",
+ "settings.material_color.role.secondary": "Secondary",
+ "settings.material_color.role.surface": "Surface",
+ "settings.material_color.role.text": "Text",
+ "settings.material_color.role.toggle": "Toggle",
+ "settings.material_color.surface.detail_format": "A={0:X2} Blur={1:0}",
+ "settings.material_color.title": "Material & Color",
+ "settings.material_color.description": "Unify Monet, wallpaper colors, semantic roles, and material surfaces.",
+ "settings.material_color.source.label": "Color source",
+ "settings.material_color.source.description": "Choose the single source used by app surfaces, components, and plugins.",
+ "settings.material_color.custom_seed.label": "Custom Monet seed",
+ "settings.material_color.wallpaper_source.label": "Wallpaper color source",
+ "settings.material_color.wallpaper_seed.label": "Seed",
+ "settings.material_color.system_material.label": "System material",
+ "settings.material_color.system_material.description": "Apply the selected material mode to windows and host surfaces.",
+ "settings.material_color.native_events.label": "Native wallpaper change events",
+ "settings.material_color.native_events.description": "Use OS wallpaper notifications first and keep polling as fallback.",
+ "settings.material_color.native_events.active": "Native wallpaper events active",
+ "settings.material_color.native_events.polling": "Polling fallback active",
+ "settings.material_color.native_events.inactive": "Wallpaper monitoring inactive",
+ "settings.material_color.refresh_interval.label": "Polling interval",
+ "settings.material_color.refresh_now": "Refresh colors",
+ "settings.material_color.preview.header": "Unified preview",
+ "settings.material_color.source_status.header": "Resolved source",
+ "settings.material_color.semantic.header": "Semantic colors",
+ "settings.material_color.surfaces.header": "Material surfaces",
+ "settings.material_color.wallpaper_source.auto": "Auto",
+ "settings.material_color.wallpaper_source.app": "App wallpaper",
+ "settings.material_color.wallpaper_source.system": "System wallpaper",
"settings.wallpaper.placement.fill": "Fill",
"settings.wallpaper.placement.fit": "Fit",
"settings.wallpaper.placement.stretch": "Stretch",
@@ -417,6 +488,11 @@
"settings.status_bar.network_speed_mode.download": "Download only",
"settings.status_bar.network_speed_transparent_background_label": "Transparent background",
"settings.status_bar.show_network_type_icon_label": "Show network type icon",
+ "settings.status_bar.clock_font_size_label": "Clock font size",
+ "settings.status_bar.network_speed_font_size_label": "Network speed font size",
+ "settings.status_bar.font_size.small": "Small",
+ "settings.status_bar.font_size.medium": "Medium",
+ "settings.status_bar.font_size.large": "Large",
"settings.status_bar.shadow_header": "Status Bar Shadow",
"settings.status_bar.shadow_desc": "Add shadow effect to the status bar for better visibility of transparent components.",
"settings.status_bar.shadow_enabled_label": "Enable shadow",
@@ -440,6 +516,7 @@
"settings.components.corner_radius.header": "Corner Design",
"settings.components.corner_radius.label": "Component Corner Radius",
"settings.components.corner_radius.description": "Adjust the shared corner radius from a square edge to a capsule-like shape, and expand the internal safe area with it.",
+ "settings.components.corner_radius.spec_tooltip": "View Corner Radius Specification",
"settings.update.title": "Update",
"settings.update.current_version_label": "Current Version",
"settings.update.latest_version_label": "Latest Release",
@@ -502,9 +579,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",
@@ -521,20 +606,235 @@
"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",
"settings.about.render_backend_label": "Render Backend",
"settings.about.render_backend_format": "Render Backend: {0}",
+ "settings.about.project_resources_header": "Project resources",
+ "settings.about.link_github": "GitHub Repository",
+ "settings.about.link_issues": "Issue Tracker",
+ "settings.about.copyright_format": "Copyright (c) 2024-{0} Lincube",
+ "settings.notifications.title": "Notifications",
+ "settings.notifications.description": "Configure global notifications, interaction behavior, limits, and send test toasts.",
+ "settings.notifications.section_header": "Notifications",
+ "settings.notifications.enable_header": "Enable notifications",
+ "settings.notifications.enable_desc": "Turn all notification toasts on or off.",
+ "settings.notifications.behavior_header": "Behavior",
+ "settings.notifications.hover_pause_header": "Pause on hover",
+ "settings.notifications.hover_pause_desc": "Pause the auto-dismiss timer while the pointer is over a notification.",
+ "settings.notifications.click_close_header": "Close on click",
+ "settings.notifications.click_close_desc": "Dismiss a notification when it is clicked.",
+ "settings.notifications.max_header": "Max per position",
+ "settings.notifications.max_desc": "Maximum notifications shown at once for each corner or edge.",
+ "settings.notifications.test_header": "Test",
+ "settings.notifications.test_notification_header": "Test notification",
+ "settings.notifications.test_notification_desc": "Pick a position and severity, then send a sample notification.",
+ "settings.notifications.default_position_header": "Default position",
+ "settings.notifications.default_position_desc": "Where notifications appear first.",
+ "settings.notifications.duration_header": "Visible duration",
+ "settings.notifications.duration_desc": "How long notifications stay on screen.",
+ "settings.notifications.position.top_left": "Top left",
+ "settings.notifications.position.top_right": "Top right",
+ "settings.notifications.position.top_center": "Top center",
+ "settings.notifications.position.bottom_left": "Bottom left",
+ "settings.notifications.position.bottom_right": "Bottom right",
+ "settings.notifications.position.bottom_center": "Bottom center",
+ "settings.notifications.position.center": "Center",
+ "settings.notifications.duration.2s": "2 seconds",
+ "settings.notifications.duration.4s": "4 seconds",
+ "settings.notifications.duration.6s": "6 seconds",
+ "settings.notifications.duration.8s": "8 seconds",
+ "settings.notifications.duration.10s": "10 seconds",
+ "settings.notifications.severity.info": "Info",
+ "settings.notifications.severity.success": "Success",
+ "settings.notifications.severity.warning": "Warning",
+ "settings.notifications.severity.error": "Error",
+ "settings.notifications.test.title_info": "Test notification",
+ "settings.notifications.test.message_info": "This is an informational test notification.",
+ "settings.notifications.test.title_success": "Succeeded",
+ "settings.notifications.test.message_success": "The task completed successfully.",
+ "settings.notifications.test.title_warning": "Warning",
+ "settings.notifications.test.message_warning": "Please review this notice.",
+ "settings.notifications.test.title_error": "Error",
+ "settings.notifications.test.message_error": "Something went wrong. Please try again.",
+ "settings.notifications.test.title_default": "Test notification",
+ "settings.notifications.test.message_default": "This is a test notification.",
+ "settings.privacy.policy_title": "Privacy Policy",
+ "settings.privacy.policy_description": "LanMountainDesktop values your privacy. We do not collect personal data without your consent.",
+ "settings.privacy.policy_loading": "Loading privacy policy...",
+ "settings.privacy.policy_error": "Failed to load privacy policy.",
+ "settings.privacy.telemetry_id_title": "Telemetry ID",
+ "settings.dev.title": "Developer",
+ "settings.dev.description": "Debugging, diagnostics, and local plugin development options.",
+ "settings.dev.infobar.title": "Preview and developer features",
+ "settings.dev.infobar.message": "These options are intended for debugging, diagnostics, and local plugin development.",
+ "settings.dev.mode_header": "Developer mode",
+ "settings.dev.mode_description": "Enable developer-focused startup helpers and diagnostics.",
+ "settings.dev.three_finger_header": "Three-finger desktop swipe",
+ "settings.dev.three_finger_description": "Enable desktop page switching gestures when the current platform supports them.",
+ "settings.dev.fused_header": "Fused desktop experience",
+ "settings.dev.fused_description": "Enable the fused desktop shell and its related experimental entry points.",
+ "settings.dev.main_window_desktop_layer_header": "Prevent covering other apps",
+ "settings.dev.main_window_desktop_layer_description": "Keep the main desktop window on the desktop layer so ordinary app windows can stay above it.",
+ "settings.dev.desktop_layer_conflict_title": "Switch desktop layer mode?",
+ "settings.dev.desktop_layer_conflict_enable_main": "Main desktop layer mode and fused desktop cannot run at the same time. Enabling this option will turn off fused desktop.",
+ "settings.dev.desktop_layer_conflict_enable_fused": "Fused desktop and main desktop layer mode cannot run at the same time. Enabling fused desktop will turn off main desktop layer mode.",
+ "settings.dev.desktop_layer_conflict_confirm": "Switch",
+ "settings.dev.desktop_layer_conflict_cancel": "Cancel",
+ "settings.dev.plugin_path_header": "Development plugin path",
+ "settings.dev.plugin_path_description": "Load a local plugin output directory for iterative debugging without packaging.",
+ "settings.dev.plugin_path_placeholder": "e.g. C:\\path\\to\\plugin\\bin\\Debug\\net10.0",
+ "settings.dev.startup_args_header": "Developer startup arguments",
+ "settings.dev.startup_args_description": "Use these launch arguments or environment variables to start the app in development scenarios.",
+ "settings.dev.cli_label": "Command-line arguments:",
+ "settings.dev.env_label": "Environment variables:",
+ "settings.dev.other_args_label": "Other arguments:",
+ "settings.dev.cli_example": "--dev-plugin or -dp ",
+ "settings.dev.env_example": "LMD_DEV_PLUGIN=",
+ "settings.dev.other_dev_mode": "--dev-mode / -dev Enable developer mode startup helpers.",
+ "settings.dev.other_hot_reload": "--hot-reload / -hr Enable hot reload for development builds.",
+ "settings.status_bar.text_capsule_placeholder": "Enter Markdown text…",
"settings.restart_dialog.title": "Restart required",
"settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
"settings.restart_dialog.restart": "Restart now",
@@ -668,6 +968,13 @@
"settings.update.source_plonds_desc": "Prefer PLONDS distribution endpoints, then automatically fallback to GitHub.",
"settings.update.status_check_failed_plonds": "PLONDS update check failed, falling back to GitHub...",
"settings.window.drawer_default": "Details",
+ "settings.search.placeholder": "Search settings",
+ "settings.search.no_results": "No matching settings",
+ "settings.search.page_hint": "Open settings page",
+ "settings.window.more_options": "More options",
+ "settings.window.restart_menu_item": "Restart app",
+ "settings.window.toggle_pane": "Toggle navigation",
+ "settings.window.back": "Back",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin market...",
@@ -721,9 +1028,13 @@
"tooltip.component_library": "Edit Desktop",
"component_library.title": "Widgets",
"component_library.empty": "Swipe to pick a category, tap to open, then drag a widget onto the desktop.",
+ "component_library.components_none": "No components.",
"component_library.drag_hint": "Drag to place",
+ "component_library.preview_unavailable": "Preview unavailable",
"component.delete": "Delete",
"component.edit": "Edit",
+ "component.move": "Move",
+ "component.resize": "Resize",
"component.editor.instance_scope": "Changes apply to this component instance only.",
"component.editor.info_header": "Component Info",
"component.editor.id_label": "Component ID",
@@ -794,8 +1105,45 @@
"desktop_clock.settings.desc": "Choose the time zone for the single clock.",
"desktop_clock.settings.timezone_label": "Time Zone",
"desktop_clock.settings.second_mode_label": "Second Hand",
+ "clock.settings.desc": "Configure the time zone and second hand animation.",
+ "clock.settings.timezone": "Time Zone",
+ "clock.settings.second_mode_label": "Second Hand",
"clock.second_mode.tick": "Tick",
"clock.second_mode.sweep": "Sweep",
+ "clockairapp.title": "Clock",
+ "clockairapp.subtitle": "World clock, stopwatch and timer",
+ "clockairapp.tab.world": "World",
+ "clockairapp.tab.stopwatch": "Stopwatch",
+ "clockairapp.tab.timer": "Timer",
+ "clockairapp.tab.settings": "Settings",
+ "clockairapp.world.local": "Local time",
+ "clockairapp.world.add": "Add",
+ "clockairapp.world.search": "Search city or time zone",
+ "clockairapp.world.count": "{0} cities",
+ "clockairapp.action.start": "Start",
+ "clockairapp.action.pause": "Pause",
+ "clockairapp.action.reset": "Reset",
+ "clockairapp.action.remove": "Remove",
+ "clockairapp.action.move_up": "Move up",
+ "clockairapp.action.move_down": "Move down",
+ "clockairapp.stopwatch.hint": "Lap timing stays in this window session.",
+ "clockairapp.stopwatch.lap": "Lap",
+ "clockairapp.stopwatch.lap_format": "Lap {0} {1}",
+ "clockairapp.timer.hint": "Choose a preset or enter custom minutes.",
+ "clockairapp.timer.apply": "Apply",
+ "clockairapp.timer.minutes": "Minutes",
+ "clockairapp.timer.finished": "Timer finished",
+ "clockairapp.timer.duration_status": "Duration {0}",
+ "clockairapp.timer.invalid": "Enter a valid minute value.",
+ "clockairapp.settings.title": "Clock settings",
+ "clockairapp.settings.time_format": "Time format",
+ "clockairapp.settings.startup_tab": "Startup page",
+ "clockairapp.settings.show_seconds": "Show seconds",
+ "clockairapp.settings.activate_timer": "Activate window when timer finishes",
+ "clockairapp.settings.time_format.system": "Follow system",
+ "clockairapp.settings.time_format.24h": "24-hour",
+ "clockairapp.settings.time_format.12h": "12-hour",
+ "clockairapp.settings.startup.last": "Last used",
"poetry.widget.loading_content": "Loading poetry...",
"poetry.widget.loading_author": "Loading...",
"poetry.widget.fetch_failed": "Poetry fetch failed",
@@ -1142,5 +1490,25 @@
"power.sleep_confirm_title": "Sleep Confirmation",
"power.sleep_confirm_message": "Are you sure you want to put the computer to sleep?",
"power.confirm_yes": "Yes",
- "power.confirm_cancel": "Cancel"
- }
+ "power.confirm_cancel": "Cancel",
+ "settings.weather.visual_style_header": "Weather Visual Style",
+ "settings.weather.visual_style_desc": "Choose the icon and component style used by desktop weather widgets.",
+ "settings.weather.visual_style.GoogleWeatherV4": "Google Weather v4",
+ "settings.weather.visual_style.Geometric": "Geometric",
+ "settings.weather.visual_style.Breezy": "Breezy Weather",
+ "settings.weather.visual_style.LemonFlutter": "Lemon Weather Flutter",
+ "settings.general.back_to_windows_button_display_header": "Back to platform button",
+ "settings.general.back_to_windows_button_display_desc": "Choose whether the Dock button shows its circle icon, text, or both.",
+ "settings.general.back_to_windows_button_display.icon_and_text": "Icon and text",
+ "settings.general.back_to_windows_button_display.icon_only": "Icon only",
+ "settings.general.back_to_windows_button_display.text_only": "Text only",
+ "settings.general.back_to_windows_icon_source_header": "Back button icon source",
+ "settings.general.back_to_windows_icon_source_desc": "Choose whether the left icon slot uses a Fluent icon or short custom text.",
+ "settings.general.back_to_windows_icon_source.fluent_icon": "Fluent icon",
+ "settings.general.back_to_windows_icon_source.text": "Text icon",
+ "settings.general.back_to_windows_fluent_icon_header": "Fluent icon",
+ "settings.general.back_to_windows_fluent_icon_desc": "Search and choose a built-in Fluent icon for the left icon slot.",
+ "settings.general.back_to_windows_icon_text_header": "Text icon",
+ "settings.general.back_to_windows_icon_text_desc": "Enter up to four characters to display as the left icon.",
+ "settings.general.back_to_windows_fluent_icon_search_placeholder": "Search icon"
+}
diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json
index eb50f9d..850d343 100644
--- a/LanMountainDesktop/Localization/ja-JP.json
+++ b/LanMountainDesktop/Localization/ja-JP.json
@@ -38,27 +38,25 @@
"settings.wallpaper.title": "壁紙",
"settings.wallpaper.description": "画像または動画を選択して、アプリウィンドウの壁紙としてすぐに適用します。",
"settings.wallpaper.current_label": "現在の壁紙",
- "settings.wallpaper.type_label": "壁紙タイプ",
- "settings.wallpaper.type.image": "画像",
- "settings.wallpaper.type.solid_color": "単色",
- "settings.wallpaper.type.system": "システム壁紙",
- "settings.wallpaper.system.label": "システム壁紙",
- "settings.wallpaper.system.unavailable": "システム壁紙を読み込めません",
- "settings.wallpaper.refresh_interval": "更新間隔",
- "settings.wallpaper.refresh_now": "今すぐ更新",
- "settings.wallpaper.refresh.30s": "30秒",
- "settings.wallpaper.refresh.1m": "1分",
- "settings.wallpaper.refresh.5m": "5分",
- "settings.wallpaper.refresh.10m": "10分",
- "settings.wallpaper.refresh.15m": "15分",
- "settings.wallpaper.refresh.30m": "30分",
- "settings.wallpaper.refresh.1h": "1時間",
- "settings.wallpaper.refresh.2h": "2時間",
- "settings.wallpaper.refresh.4h": "4時間",
- "settings.wallpaper.refresh.8h": "8時間",
- "settings.wallpaper.refresh.12h": "12時間",
- "settings.wallpaper.refresh.24h": "24時間",
- "settings.wallpaper.color_label": "壁紙の色",
+"settings.wallpaper.type_label": "壁紙タイプ",
+"settings.wallpaper.type.image": "画像",
+"settings.wallpaper.type.solid_color": "単色",
+"settings.wallpaper.type.system": "システム壁紙",
+"settings.wallpaper.refresh_interval": "更新間隔",
+"settings.wallpaper.refresh_now": "今すぐ更新",
+"settings.wallpaper.refresh.30s": "30秒",
+"settings.wallpaper.refresh.1m": "1分",
+"settings.wallpaper.refresh.5m": "5分",
+"settings.wallpaper.refresh.10m": "10分",
+"settings.wallpaper.refresh.15m": "15分",
+"settings.wallpaper.refresh.30m": "30分",
+"settings.wallpaper.refresh.1h": "1時間",
+"settings.wallpaper.refresh.2h": "2時間",
+"settings.wallpaper.refresh.4h": "4時間",
+"settings.wallpaper.refresh.8h": "8時間",
+"settings.wallpaper.refresh.12h": "12時間",
+"settings.wallpaper.refresh.24h": "24時間",
+"settings.wallpaper.color_label": "壁紙の色",
"settings.wallpaper.placement_label": "配置",
"settings.wallpaper.placement_desc": "画像がデスクトップにどのように表示されるかを調整します。",
"settings.wallpaper.pick_button": "ファイルを参照",
@@ -133,7 +131,7 @@
"settings.privacy.policy_hint_prefix": "詳細については、",
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
"settings.weather.title": "天気",
- "settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。",
+ "settings.weather.description": "天気の場所、天気プレビュー、起動時の位置情報取得動作を設定します。",
"settings.weather.location_source_header": "位置情報ソース",
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
"settings.weather.mode_city_search": "都市検索",
@@ -175,6 +173,19 @@
"settings.weather.settings_section": "設定",
"settings.weather.preview_panel_header": "天気プレビュー",
"settings.weather.preview_panel_desc": "現在の天気サービスの状態を更新して確認します。",
+ "settings.weather.preview_metrics_header": "現在の気象データ",
+ "settings.weather.preview_alerts_header": "気象アラート",
+ "settings.weather.preview_no_alerts": "有効な気象アラートはありません。",
+ "settings.weather.metric_humidity": "湿度",
+ "settings.weather.metric_aqi": "AQI",
+ "settings.weather.metric_wind": "風",
+ "settings.weather.metric_feels_like": "体感",
+ "settings.weather.metric_precipitation": "降水確率",
+ "settings.weather.metric_sun": "日の出 / 日の入",
+ "settings.weather.alert_untitled": "気象アラート",
+ "settings.weather.alert_no_detail": "詳細はありません。",
+ "settings.weather.alert_active": "有効なアラート",
+ "settings.weather.alert_published_format": "{0}に発表",
"settings.weather.refresh_button": "更新",
"settings.weather.preview_updated_format": "{0}に更新",
"settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。",
@@ -289,6 +300,14 @@
"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": "外観",
"settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。",
"settings.appearance.theme_header": "テーマ",
@@ -328,7 +347,14 @@
"settings.appearance.preview.apply_seed": "適用",
"settings.appearance.preview.wallpaper_candidates": "壁紙シード候補",
"settings.appearance.preview.wallpaper_current": "現在",
- "settings.wallpaper.placement.fill": "フィル",
+ "settings.material_color.preview.wallpaper_current": "Current",
+ "settings.material_color.theme_color_mode.neutral": "Default neutral",
+ "settings.material_color.theme_color_mode.user": "User theme color Monet",
+ "settings.material_color.theme_color_mode.wallpaper": "Wallpaper Monet",
+ "settings.material_color.system_material.auto": "Auto (recommended)",
+ "settings.material_color.system_material.none": "None",
+ "settings.material_color.system_material.mica": "Mica",
+ "settings.material_color.system_material.acrylic": "Acrylic", "settings.wallpaper.placement.fill": "フィル",
"settings.wallpaper.placement.fit": "フィット",
"settings.wallpaper.placement.stretch": "ストレッチ",
"settings.wallpaper.placement.center": "中央",
@@ -466,7 +492,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": "コードネーム",
@@ -728,6 +824,40 @@
"desktop_clock.settings.second_mode_label": "秒針",
"clock.second_mode.tick": "ティック",
"clock.second_mode.sweep": "スイープ",
+ "clockairapp.title": "時計",
+ "clockairapp.subtitle": "世界時計、ストップウォッチ、タイマー",
+ "clockairapp.tab.world": "世界時計",
+ "clockairapp.tab.stopwatch": "ストップウォッチ",
+ "clockairapp.tab.timer": "タイマー",
+ "clockairapp.tab.settings": "設定",
+ "clockairapp.world.local": "ローカル時刻",
+ "clockairapp.world.add": "追加",
+ "clockairapp.world.search": "都市またはタイムゾーンを検索",
+ "clockairapp.world.count": "{0} 都市",
+ "clockairapp.action.start": "開始",
+ "clockairapp.action.pause": "一時停止",
+ "clockairapp.action.reset": "リセット",
+ "clockairapp.action.remove": "削除",
+ "clockairapp.action.move_up": "上へ",
+ "clockairapp.action.move_down": "下へ",
+ "clockairapp.stopwatch.hint": "ラップは現在のウィンドウセッション内に保持されます。",
+ "clockairapp.stopwatch.lap": "ラップ",
+ "clockairapp.stopwatch.lap_format": "ラップ {0} {1}",
+ "clockairapp.timer.hint": "プリセットを選ぶか、分数を入力します。",
+ "clockairapp.timer.apply": "適用",
+ "clockairapp.timer.minutes": "分",
+ "clockairapp.timer.finished": "タイマー終了",
+ "clockairapp.timer.duration_status": "時間 {0}",
+ "clockairapp.timer.invalid": "有効な分数を入力してください。",
+ "clockairapp.settings.title": "時計設定",
+ "clockairapp.settings.time_format": "時刻形式",
+ "clockairapp.settings.startup_tab": "起動ページ",
+ "clockairapp.settings.show_seconds": "秒を表示",
+ "clockairapp.settings.activate_timer": "タイマー終了時にウィンドウを前面へ",
+ "clockairapp.settings.time_format.system": "システムに従う",
+ "clockairapp.settings.time_format.24h": "24時間",
+ "clockairapp.settings.time_format.12h": "12時間",
+ "clockairapp.settings.startup.last": "前回使用",
"poetry.widget.loading_content": "詩を読み込み中...",
"poetry.widget.loading_author": "読み込み中...",
"poetry.widget.fetch_failed": "詩の取得に失敗しました",
@@ -1035,5 +1165,25 @@
"single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ プラグイン「{0}」が正常にインストールされました!有効にするには、アプリケーションを再起動してください。",
"market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか?",
- "component.settings.color_scheme": "カラースキーム"
+ "component.settings.color_scheme": "カラースキーム",
+ "settings.weather.visual_style_header": "天気ビジュアルスタイル",
+ "settings.weather.visual_style_desc": "デスクトップ天気ウィジェットのアイコンとスタイルを選択します。",
+ "settings.weather.visual_style.GoogleWeatherV4": "Google Weather v4",
+ "settings.weather.visual_style.Geometric": "Geometric",
+ "settings.weather.visual_style.Breezy": "Breezy Weather",
+ "settings.weather.visual_style.LemonFlutter": "Lemon Weather Flutter",
+ "settings.general.back_to_windows_button_display_header": "プラットフォームに戻るボタン",
+ "settings.general.back_to_windows_button_display_desc": "Dock ボタンに円形アイコン、テキスト、またはその両方を表示するかを選択します。",
+ "settings.general.back_to_windows_button_display.icon_and_text": "アイコンとテキスト",
+ "settings.general.back_to_windows_button_display.icon_only": "アイコンのみ",
+ "settings.general.back_to_windows_button_display.text_only": "テキストのみ",
+ "settings.general.back_to_windows_icon_source_header": "戻るボタンのアイコンソース",
+ "settings.general.back_to_windows_icon_source_desc": "左側のアイコン枠に Fluent アイコンまたは短いテキストを使うかを選択します。",
+ "settings.general.back_to_windows_icon_source.fluent_icon": "Fluent アイコン",
+ "settings.general.back_to_windows_icon_source.text": "テキストアイコン",
+ "settings.general.back_to_windows_fluent_icon_header": "Fluent アイコン",
+ "settings.general.back_to_windows_fluent_icon_desc": "左側のアイコン枠に使う組み込み Fluent アイコンを検索して選択します。",
+ "settings.general.back_to_windows_icon_text_header": "テキストアイコン",
+ "settings.general.back_to_windows_icon_text_desc": "左側のアイコンとして表示する文字を最大4文字まで入力します。",
+ "settings.general.back_to_windows_fluent_icon_search_placeholder": "アイコンを検索"
}
diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json
index e8b9640..81d8637 100644
--- a/LanMountainDesktop/Localization/ko-KR.json
+++ b/LanMountainDesktop/Localization/ko-KR.json
@@ -132,7 +132,7 @@
"settings.privacy.policy_hint_prefix": "자세한 내용은",
"settings.privacy.view_policy": "개인정보 처리방침 보기",
"settings.weather.title": "날씨",
- "settings.weather.description": "날씨 위치, Xiaomi 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
+ "settings.weather.description": "날씨 위치, 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
"settings.weather.location_source_header": "위치 소스",
"settings.weather.location_source_desc": "날씨 컴포넌트가 현재 위치를 해석하는 방법을 선택합니다.",
"settings.weather.mode_city_search": "도시 검색",
@@ -174,6 +174,19 @@
"settings.weather.settings_section": "설정",
"settings.weather.preview_panel_header": "날씨 미리보기",
"settings.weather.preview_panel_desc": "현재 날씨 서비스 상태를 새로고침하고 확인합니다.",
+ "settings.weather.preview_metrics_header": "현재 날씨 데이터",
+ "settings.weather.preview_alerts_header": "기상 경보",
+ "settings.weather.preview_no_alerts": "활성 기상 경보가 없습니다.",
+ "settings.weather.metric_humidity": "습도",
+ "settings.weather.metric_aqi": "AQI",
+ "settings.weather.metric_wind": "바람",
+ "settings.weather.metric_feels_like": "체감",
+ "settings.weather.metric_precipitation": "강수 확률",
+ "settings.weather.metric_sun": "일출 / 일몰",
+ "settings.weather.alert_untitled": "기상 경보",
+ "settings.weather.alert_no_detail": "제공된 세부 정보가 없습니다.",
+ "settings.weather.alert_active": "활성 경보",
+ "settings.weather.alert_published_format": "{0}에 발표됨",
"settings.weather.refresh_button": "새로고침",
"settings.weather.preview_updated_format": "{0}에 업데이트됨",
"settings.weather.preview_hint": "테스트 가져오기를 통해 날씨 구성을 빠르게 확인할 수 있습니다.",
@@ -335,6 +348,14 @@
"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": "외관",
"settings.appearance.description": "테마 소스, 시스템 소재 및 창 외관을 조정합니다.",
"settings.appearance.theme_header": "테마",
@@ -374,7 +395,14 @@
"settings.appearance.preview.apply_seed": "적용",
"settings.appearance.preview.wallpaper_candidates": "배경화면 후보 테마 색상",
"settings.appearance.preview.wallpaper_current": "현재",
- "settings.wallpaper.placement.fill": "채우기",
+ "settings.material_color.preview.wallpaper_current": "Current",
+ "settings.material_color.theme_color_mode.neutral": "Default neutral",
+ "settings.material_color.theme_color_mode.user": "User theme color Monet",
+ "settings.material_color.theme_color_mode.wallpaper": "Wallpaper Monet",
+ "settings.material_color.system_material.auto": "Auto (recommended)",
+ "settings.material_color.system_material.none": "None",
+ "settings.material_color.system_material.mica": "Mica",
+ "settings.material_color.system_material.acrylic": "Acrylic", "settings.wallpaper.placement.fill": "채우기",
"settings.wallpaper.placement.fit": "맞추기",
"settings.wallpaper.placement.stretch": "늘리기",
"settings.wallpaper.placement.center": "가운데",
@@ -512,7 +540,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": "버전 코드명",
@@ -772,6 +870,40 @@
"desktop_clock.settings.second_mode_label": "초침 방식",
"clock.second_mode.tick": "똑딱이",
"clock.second_mode.sweep": "스윕",
+ "clockairapp.title": "시계",
+ "clockairapp.subtitle": "세계 시계, 스톱워치, 타이머",
+ "clockairapp.tab.world": "세계 시계",
+ "clockairapp.tab.stopwatch": "스톱워치",
+ "clockairapp.tab.timer": "타이머",
+ "clockairapp.tab.settings": "설정",
+ "clockairapp.world.local": "현지 시간",
+ "clockairapp.world.add": "추가",
+ "clockairapp.world.search": "도시 또는 시간대 검색",
+ "clockairapp.world.count": "{0}개 도시",
+ "clockairapp.action.start": "시작",
+ "clockairapp.action.pause": "일시정지",
+ "clockairapp.action.reset": "초기화",
+ "clockairapp.action.remove": "삭제",
+ "clockairapp.action.move_up": "위로",
+ "clockairapp.action.move_down": "아래로",
+ "clockairapp.stopwatch.hint": "랩 기록은 현재 창 세션에만 유지됩니다.",
+ "clockairapp.stopwatch.lap": "랩",
+ "clockairapp.stopwatch.lap_format": "랩 {0} {1}",
+ "clockairapp.timer.hint": "프리셋을 선택하거나 사용자 지정 분을 입력하세요.",
+ "clockairapp.timer.apply": "적용",
+ "clockairapp.timer.minutes": "분",
+ "clockairapp.timer.finished": "타이머 종료",
+ "clockairapp.timer.duration_status": "시간 {0}",
+ "clockairapp.timer.invalid": "올바른 분 값을 입력하세요.",
+ "clockairapp.settings.title": "시계 설정",
+ "clockairapp.settings.time_format": "시간 형식",
+ "clockairapp.settings.startup_tab": "시작 페이지",
+ "clockairapp.settings.show_seconds": "초 표시",
+ "clockairapp.settings.activate_timer": "타이머 종료 시 창 활성화",
+ "clockairapp.settings.time_format.system": "시스템 설정 따르기",
+ "clockairapp.settings.time_format.24h": "24시간",
+ "clockairapp.settings.time_format.12h": "12시간",
+ "clockairapp.settings.startup.last": "마지막 사용",
"poetry.widget.loading_content": "시 불러오는 중",
"poetry.widget.loading_author": "로딩 중",
"poetry.widget.fetch_failed": "시 가져오기 실패",
@@ -1079,5 +1211,25 @@
"single_instance.notice.description": "앱이 이미 실행 중이므로 여러 번 클릭하여 열 필요가 없습니다.",
"single_instance.notice.button": "확인",
"market.status.install_success_restart_format": "✓ 플러그인 '{0}' 설치 성공! 활성화하려면 앱을 재시작하세요.",
- "market.dialog.restart_message_format": "플러그인 '{0}'이(가) 성공적으로 설치되었습니다.\n\n이 플러그인을 사용하려면 앱을 즉시 재시작해야 합니다.\n\n지금 재시작하시겠습니까?"
- }
+ "market.dialog.restart_message_format": "플러그인 '{0}'이(가) 성공적으로 설치되었습니다.\n\n이 플러그인을 사용하려면 앱을 즉시 재시작해야 합니다.\n\n지금 재시작하시겠습니까?",
+ "settings.weather.visual_style_header": "날씨 비주얼 스타일",
+ "settings.weather.visual_style_desc": "데스크톱 날씨 위젯에 사용할 아이콘과 스타일을 선택합니다.",
+ "settings.weather.visual_style.GoogleWeatherV4": "Google Weather v4",
+ "settings.weather.visual_style.Geometric": "Geometric",
+ "settings.weather.visual_style.Breezy": "Breezy Weather",
+ "settings.weather.visual_style.LemonFlutter": "Lemon Weather Flutter",
+ "settings.general.back_to_windows_button_display_header": "플랫폼으로 돌아가기 버튼",
+ "settings.general.back_to_windows_button_display_desc": "Dock 버튼에 원형 아이콘, 텍스트 또는 둘 다 표시할지 선택합니다.",
+ "settings.general.back_to_windows_button_display.icon_and_text": "아이콘과 텍스트",
+ "settings.general.back_to_windows_button_display.icon_only": "아이콘만",
+ "settings.general.back_to_windows_button_display.text_only": "텍스트만",
+ "settings.general.back_to_windows_icon_source_header": "돌아가기 버튼 아이콘 소스",
+ "settings.general.back_to_windows_icon_source_desc": "왼쪽 아이콘 영역에 Fluent 아이콘 또는 짧은 텍스트를 사용할지 선택합니다.",
+ "settings.general.back_to_windows_icon_source.fluent_icon": "Fluent 아이콘",
+ "settings.general.back_to_windows_icon_source.text": "텍스트 아이콘",
+ "settings.general.back_to_windows_fluent_icon_header": "Fluent 아이콘",
+ "settings.general.back_to_windows_fluent_icon_desc": "왼쪽 아이콘 영역에 사용할 내장 Fluent 아이콘을 검색하고 선택합니다.",
+ "settings.general.back_to_windows_icon_text_header": "텍스트 아이콘",
+ "settings.general.back_to_windows_icon_text_desc": "왼쪽 아이콘으로 표시할 문자를 최대 4자까지 입력합니다.",
+ "settings.general.back_to_windows_fluent_icon_search_placeholder": "아이콘 검색"
+}
diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json
index 49b28ad..118be85 100644
--- a/LanMountainDesktop/Localization/zh-CN.json
+++ b/LanMountainDesktop/Localization/zh-CN.json
@@ -38,27 +38,25 @@
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
- "settings.wallpaper.type_label": "壁纸类型",
- "settings.wallpaper.type.image": "图片",
- "settings.wallpaper.type.solid_color": "纯色",
- "settings.wallpaper.type.system": "系统壁纸",
- "settings.wallpaper.system.label": "系统壁纸",
- "settings.wallpaper.system.unavailable": "无法读取系统壁纸",
- "settings.wallpaper.refresh_interval": "刷新频率",
- "settings.wallpaper.refresh_now": "立即刷新",
- "settings.wallpaper.refresh.30s": "30 秒",
- "settings.wallpaper.refresh.1m": "1 分钟",
- "settings.wallpaper.refresh.5m": "5 分钟",
- "settings.wallpaper.refresh.10m": "10 分钟",
- "settings.wallpaper.refresh.15m": "15 分钟",
- "settings.wallpaper.refresh.30m": "30 分钟",
- "settings.wallpaper.refresh.1h": "1 小时",
- "settings.wallpaper.refresh.2h": "2 小时",
- "settings.wallpaper.refresh.4h": "4 小时",
- "settings.wallpaper.refresh.8h": "8 小时",
- "settings.wallpaper.refresh.12h": "12 小时",
- "settings.wallpaper.refresh.24h": "24 小时",
- "settings.wallpaper.color_label": "壁纸颜色",
+"settings.wallpaper.type_label": "壁纸类型",
+"settings.wallpaper.type.image": "图片",
+"settings.wallpaper.type.solid_color": "纯色",
+"settings.wallpaper.type.system": "系统壁纸",
+"settings.wallpaper.refresh_interval": "刷新频率",
+"settings.wallpaper.refresh_now": "立即刷新",
+"settings.wallpaper.refresh.30s": "30 秒",
+"settings.wallpaper.refresh.1m": "1 分钟",
+"settings.wallpaper.refresh.5m": "5 分钟",
+"settings.wallpaper.refresh.10m": "10 分钟",
+"settings.wallpaper.refresh.15m": "15 分钟",
+"settings.wallpaper.refresh.30m": "30 分钟",
+"settings.wallpaper.refresh.1h": "1 小时",
+"settings.wallpaper.refresh.2h": "2 小时",
+"settings.wallpaper.refresh.4h": "4 小时",
+"settings.wallpaper.refresh.8h": "8 小时",
+"settings.wallpaper.refresh.12h": "12 小时",
+"settings.wallpaper.refresh.24h": "24 小时",
+"settings.wallpaper.color_label": "壁纸颜色",
"settings.wallpaper.custom_color_tooltip": "自定义颜色",
"settings.wallpaper.custom_color_apply": "应用",
"settings.wallpaper.placement_label": "显示方式",
@@ -69,6 +67,12 @@
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
"settings.wallpaper.import_failed": "导入壁纸文件失败。",
"settings.wallpaper.image_applied": "图片壁纸已应用。",
+ "settings.wallpaper.video_applied": "视频壁纸已应用。",
+ "settings.wallpaper.video_restored": "已从保存的设置恢复视频壁纸。",
+ "settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
+ "settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
+ "settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}",
+ "settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.unsupported_file": "所选文件类型不受支持。",
"settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}",
"settings.wallpaper.mode_format": "壁纸模式:{0}。",
@@ -102,6 +106,7 @@
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
+ "settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
"settings.status_bar.title": "状态栏",
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
@@ -128,7 +133,7 @@
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
"settings.privacy.view_policy": "查看我们的隐私政策",
"settings.weather.title": "天气",
- "settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
+ "settings.weather.description": "配置天气位置、天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源",
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
"settings.weather.mode_city_search": "城市搜索",
@@ -170,6 +175,19 @@
"settings.weather.settings_section": "设置",
"settings.weather.preview_panel_header": "天气预览",
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
+ "settings.weather.preview_metrics_header": "当前天气数据",
+ "settings.weather.preview_alerts_header": "气象预警",
+ "settings.weather.preview_no_alerts": "暂无活动气象预警。",
+ "settings.weather.metric_humidity": "湿度",
+ "settings.weather.metric_aqi": "AQI",
+ "settings.weather.metric_wind": "风力",
+ "settings.weather.metric_feels_like": "体感",
+ "settings.weather.metric_precipitation": "降水概率",
+ "settings.weather.metric_sun": "日出 / 日落",
+ "settings.weather.alert_untitled": "气象预警",
+ "settings.weather.alert_no_detail": "暂无预警详情。",
+ "settings.weather.alert_active": "活动预警",
+ "settings.weather.alert_published_format": "发布于 {0}",
"settings.weather.refresh_button": "刷新",
"settings.weather.preview_updated_format": "更新于 {0}",
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
@@ -329,6 +347,7 @@
"settings.region.language_zh": "中文",
"settings.region.language_en": "英文",
"settings.region.language_ja": "日文",
+ "settings.region.language_ko": "韩文",
"settings.region.timezone_header": "时区",
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
"settings.region.applied_format": "语言已切换为:{0}",
@@ -341,6 +360,19 @@
"settings.general.preview_time_label": "时间",
"settings.general.preview_date_label": "日期",
"settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。",
+ "settings.general.fade_transition_header": "淡入淡出启动过渡",
+ "settings.general.slide_transition_header": "滑入启动过渡",
+ "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": "外观",
"settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
"settings.appearance.theme_header": "主题",
@@ -362,13 +394,18 @@
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
+ "settings.appearance.corner_radius.label": "全局圆角样式",
+ "settings.appearance.corner_radius.description": "选择固定的全局圆角样式,受 HyperOS 启发。",
"component.color_scheme.follow_system": "跟随系统配色",
"component.color_scheme.native": "使用组件自定义配色",
+ "component.settings.color_scheme": "配色方案",
+ "settings.appearance.system_material.auto": "自动(推荐)",
"settings.appearance.system_material.none": "无",
- "settings.appearance.system_material.mica": "Mica",
- "settings.appearance.system_material.acrylic": "Acrylic",
+ "settings.appearance.system_material.mica": "云母",
+ "settings.appearance.system_material.acrylic": "亚克力",
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
+ "settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica,在 Windows 10 优先使用 Acrylic,不可用时回退到无材质。",
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
"settings.appearance.preview.primary": "主色",
"settings.appearance.preview.secondary": "次色",
@@ -380,6 +417,45 @@
"settings.appearance.preview.apply_seed": "应用",
"settings.appearance.preview.wallpaper_candidates": "壁纸候选主题色",
"settings.appearance.preview.wallpaper_current": "当前",
+ "settings.material_color.preview.wallpaper_current": "当前",
+ "settings.material_color.theme_color_mode.neutral": "默认中性",
+ "settings.material_color.theme_color_mode.user": "用户主题色 Monet",
+ "settings.material_color.theme_color_mode.wallpaper": "壁纸 Monet",
+ "settings.material_color.system_material.auto": "自动(推荐)",
+ "settings.material_color.system_material.none": "无",
+ "settings.material_color.system_material.mica": "Mica",
+ "settings.material_color.system_material.acrylic": "亚克力",
+ "settings.material_color.source.fallback": "回退",
+ "settings.material_color.role.accent": "强调色",
+ "settings.material_color.role.primary": "主色",
+ "settings.material_color.role.secondary": "次色",
+ "settings.material_color.role.surface": "表面",
+ "settings.material_color.role.text": "文字",
+ "settings.material_color.role.toggle": "开关",
+ "settings.material_color.surface.detail_format": "Alpha={0:X2} 模糊={1:0}",
+ "settings.material_color.title": "材质与颜色",
+ "settings.material_color.description": "统一莫奈色、壁纸颜色、语义角色和材质表面。",
+ "settings.material_color.source.label": "颜色来源",
+ "settings.material_color.source.description": "选择应用表面、组件和插件使用的单一来源。",
+ "settings.material_color.custom_seed.label": "自定义莫奈种子色",
+ "settings.material_color.wallpaper_source.label": "壁纸颜色来源",
+ "settings.material_color.wallpaper_seed.label": "种子色",
+ "settings.material_color.system_material.label": "系统材质",
+ "settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。",
+ "settings.material_color.native_events.label": "原生壁纸变更事件",
+ "settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。",
+ "settings.material_color.native_events.active": "原生壁纸事件已激活",
+ "settings.material_color.native_events.polling": "轮询回退已激活",
+ "settings.material_color.native_events.inactive": "壁纸监控未激活",
+ "settings.material_color.refresh_interval.label": "轮询间隔",
+ "settings.material_color.refresh_now": "刷新颜色",
+ "settings.material_color.preview.header": "统一预览",
+ "settings.material_color.source_status.header": "已解析来源",
+ "settings.material_color.semantic.header": "语义颜色",
+ "settings.material_color.surfaces.header": "材质表面",
+ "settings.material_color.wallpaper_source.auto": "自动",
+ "settings.material_color.wallpaper_source.app": "应用壁纸",
+ "settings.material_color.wallpaper_source.system": "系统壁纸",
"settings.wallpaper.placement.fill": "填充",
"settings.wallpaper.placement.fit": "适应",
"settings.wallpaper.placement.stretch": "拉伸",
@@ -412,6 +488,11 @@
"settings.status_bar.network_speed_mode.download": "仅下载",
"settings.status_bar.network_speed_transparent_background_label": "透明背景",
"settings.status_bar.show_network_type_icon_label": "显示网络类型图标",
+ "settings.status_bar.clock_font_size_label": "时钟字号",
+ "settings.status_bar.network_speed_font_size_label": "网速字号",
+ "settings.status_bar.font_size.small": "小",
+ "settings.status_bar.font_size.medium": "中",
+ "settings.status_bar.font_size.large": "大",
"settings.status_bar.shadow_header": "状态栏阴影",
"settings.status_bar.shadow_desc": "为状态栏添加阴影效果,使透明背景的组件更清晰。",
"settings.status_bar.shadow_enabled_label": "启用阴影",
@@ -435,6 +516,7 @@
"settings.components.corner_radius.header": "圆角设计",
"settings.components.corner_radius.label": "组件圆角",
"settings.components.corner_radius.description": "将组件容器圆角从直角连续调到接近胶囊的形态,并随圆角增大同步扩展内部安全区。",
+ "settings.components.corner_radius.spec_tooltip": "查看圆角规范",
"settings.update.title": "更新",
"settings.update.current_version_label": "当前版本",
"settings.update.latest_version_label": "最新发布",
@@ -524,7 +606,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": "版本代号",
@@ -541,6 +693,91 @@
"settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
+ "filepicker.video_files": "视频文件",
+ "settings.notifications.title": "通知",
+ "settings.notifications.description": "配置全局通知开关、交互行为与数量上限,并可发送测试通知。",
+ "settings.notifications.section_header": "通知",
+ "settings.notifications.enable_header": "启用通知",
+ "settings.notifications.enable_desc": "开启或关闭全局通知功能。",
+ "settings.notifications.behavior_header": "行为",
+ "settings.notifications.hover_pause_header": "悬停暂停计时",
+ "settings.notifications.hover_pause_desc": "鼠标悬停时暂停自动关闭倒计时。",
+ "settings.notifications.click_close_header": "点击关闭",
+ "settings.notifications.click_close_desc": "点击通知后立即关闭。",
+ "settings.notifications.max_header": "每区域最大数量",
+ "settings.notifications.max_desc": "同一位置最多同时显示多少条通知。",
+ "settings.notifications.test_header": "测试",
+ "settings.notifications.test_notification_header": "测试通知",
+ "settings.notifications.test_notification_desc": "选择位置与类型后发送一条测试通知。",
+ "settings.notifications.default_position_header": "默认位置",
+ "settings.notifications.default_position_desc": "通知首次出现的位置。",
+ "settings.notifications.duration_header": "显示时长",
+ "settings.notifications.duration_desc": "通知在屏幕上保持可见的时间。",
+ "settings.notifications.position.top_left": "左上角",
+ "settings.notifications.position.top_right": "右上角",
+ "settings.notifications.position.top_center": "正上方",
+ "settings.notifications.position.bottom_left": "左下角",
+ "settings.notifications.position.bottom_right": "右下角",
+ "settings.notifications.position.bottom_center": "正下方",
+ "settings.notifications.position.center": "正中央",
+ "settings.notifications.duration.2s": "2 秒",
+ "settings.notifications.duration.4s": "4 秒",
+ "settings.notifications.duration.6s": "6 秒",
+ "settings.notifications.duration.8s": "8 秒",
+ "settings.notifications.duration.10s": "10 秒",
+ "settings.notifications.severity.info": "信息",
+ "settings.notifications.severity.success": "成功",
+ "settings.notifications.severity.warning": "警告",
+ "settings.notifications.severity.error": "错误",
+ "settings.notifications.test.title_info": "测试通知",
+ "settings.notifications.test.message_info": "这是一条信息类型的测试通知。",
+ "settings.notifications.test.title_success": "操作成功",
+ "settings.notifications.test.message_success": "任务已完成。",
+ "settings.notifications.test.title_warning": "警告提示",
+ "settings.notifications.test.message_warning": "请注意检查。",
+ "settings.notifications.test.title_error": "错误报告",
+ "settings.notifications.test.message_error": "操作失败,请重试。",
+ "settings.notifications.test.title_default": "测试通知",
+ "settings.notifications.test.message_default": "这是一条测试通知。",
+ "settings.privacy.policy_title": "隐私政策",
+ "settings.privacy.policy_description": "阑山桌面尊重您的隐私。未经您的同意,我们不会收集个人数据。",
+ "settings.privacy.policy_loading": "正在加载隐私政策...",
+ "settings.privacy.policy_error": "加载隐私政策失败。",
+ "settings.privacy.telemetry_id_title": "遥测 ID",
+ "settings.dev.title": "开发者",
+ "settings.dev.description": "调试、诊断与本地插件开发相关选项。",
+ "settings.dev.infobar.title": "预览与开发者功能",
+ "settings.dev.infobar.message": "以下选项适用于调试、诊断与本地插件开发场景。",
+ "settings.dev.mode_header": "开发者模式",
+ "settings.dev.mode_description": "启用面向开发者的启动辅助与诊断信息。",
+ "settings.dev.three_finger_header": "三指滑动切换桌面页",
+ "settings.dev.three_finger_description": "在当前平台支持时,启用手势在桌面分页间切换。",
+ "settings.dev.fused_header": "融合桌面体验",
+ "settings.dev.fused_description": "启用融合桌面壳及相关实验入口。",
+ "settings.dev.main_window_desktop_layer_header": "防遮挡其它应用窗口",
+ "settings.dev.main_window_desktop_layer_description": "让主桌面窗口保持在桌面层,使普通应用窗口可以显示在它上方。",
+ "settings.dev.desktop_layer_conflict_title": "切换桌面层模式?",
+ "settings.dev.desktop_layer_conflict_enable_main": "主桌面桌面层模式不能和融合桌面同时运行。开启此选项将关闭融合桌面。",
+ "settings.dev.desktop_layer_conflict_enable_fused": "融合桌面不能和主桌面桌面层模式同时运行。开启融合桌面将关闭主桌面桌面层模式。",
+ "settings.dev.desktop_layer_conflict_confirm": "切换",
+ "settings.dev.desktop_layer_conflict_cancel": "取消",
+ "settings.dev.plugin_path_header": "开发插件路径",
+ "settings.dev.plugin_path_description": "加载本地插件输出目录以便免打包迭代调试。",
+ "settings.dev.plugin_path_placeholder": "例如:C:\\path\\to\\plugin\\bin\\Debug\\net10.0",
+ "settings.dev.startup_args_header": "开发者启动参数",
+ "settings.dev.startup_args_description": "可使用下列命令行参数或环境变量启动应用以进行开发。",
+ "settings.dev.cli_label": "命令行参数:",
+ "settings.dev.env_label": "环境变量:",
+ "settings.dev.other_args_label": "其它参数:",
+ "settings.dev.cli_example": "--dev-plugin 或 -dp ",
+ "settings.dev.env_example": "LMD_DEV_PLUGIN=",
+ "settings.dev.other_dev_mode": "--dev-mode / -dev 启用开发者模式启动辅助。",
+ "settings.dev.other_hot_reload": "--hot-reload / -hr 为开发构建启用热重载。",
+ "settings.about.project_resources_header": "项目资源",
+ "settings.about.link_github": "GitHub 仓库",
+ "settings.about.link_issues": "问题反馈",
+ "settings.about.copyright_format": "Copyright (c) 2024-{0} Lincube",
+ "settings.status_bar.text_capsule_placeholder": "请输入 Markdown 文本…",
"common.day": "日间",
"common.night": "夜间",
"common.back": "返回",
@@ -662,6 +899,13 @@
"settings.update.source_plonds_desc": "优先使用 PLONDS 分发端点,不可用时自动回退到 GitHub。",
"settings.update.status_check_failed_plonds": "PLONDS 更新检查失败,正在回退到 GitHub...",
"settings.window.drawer_default": "详情",
+ "settings.search.placeholder": "搜索设置",
+ "settings.search.no_results": "没有匹配的设置",
+ "settings.search.page_hint": "打开设置页面",
+ "settings.window.more_options": "更多选项",
+ "settings.window.restart_menu_item": "重启应用",
+ "settings.window.toggle_pane": "展开或收起导航",
+ "settings.window.back": "返回",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件目录...",
@@ -715,9 +959,13 @@
"tooltip.component_library": "桌面编辑",
"component_library.title": "桌面编辑",
"component_library.empty": "左右滑动选择类别,点击进入,然后拖动组件到桌面放置。",
+ "component_library.components_none": "暂无组件",
"component_library.drag_hint": "拖动放置",
+ "component_library.preview_unavailable": "预览不可用",
"component.delete": "删除",
"component.edit": "编辑",
+ "component.move": "移动",
+ "component.resize": "调整大小",
"component.editor.instance_scope": "设置仅对当前组件实例生效。",
"component.editor.info_header": "组件信息",
"component.editor.id_label": "组件 ID",
@@ -787,8 +1035,45 @@
"desktop_clock.settings.desc": "为单时钟选择时区。",
"desktop_clock.settings.timezone_label": "时区",
"desktop_clock.settings.second_mode_label": "秒针方式",
+ "clock.settings.desc": "配置时钟组件的时区和秒针动画。",
+ "clock.settings.timezone": "时区",
+ "clock.settings.second_mode_label": "秒针方式",
"clock.second_mode.tick": "跳针",
"clock.second_mode.sweep": "扫针",
+ "clockairapp.title": "时钟",
+ "clockairapp.subtitle": "世界时钟、秒表和计时器",
+ "clockairapp.tab.world": "世界时钟",
+ "clockairapp.tab.stopwatch": "秒表",
+ "clockairapp.tab.timer": "计时器",
+ "clockairapp.tab.settings": "设置",
+ "clockairapp.world.local": "本地时间",
+ "clockairapp.world.add": "添加",
+ "clockairapp.world.search": "搜索城市或时区",
+ "clockairapp.world.count": "{0} 个城市",
+ "clockairapp.action.start": "开始",
+ "clockairapp.action.pause": "暂停",
+ "clockairapp.action.reset": "重置",
+ "clockairapp.action.remove": "移除",
+ "clockairapp.action.move_up": "上移",
+ "clockairapp.action.move_down": "下移",
+ "clockairapp.stopwatch.hint": "计次记录仅保留在当前窗口会话中。",
+ "clockairapp.stopwatch.lap": "计次",
+ "clockairapp.stopwatch.lap_format": "计次 {0} {1}",
+ "clockairapp.timer.hint": "选择预设时长,或输入自定义分钟数。",
+ "clockairapp.timer.apply": "应用",
+ "clockairapp.timer.minutes": "分钟",
+ "clockairapp.timer.finished": "计时结束",
+ "clockairapp.timer.duration_status": "时长 {0}",
+ "clockairapp.timer.invalid": "请输入有效的分钟数。",
+ "clockairapp.settings.title": "时钟设置",
+ "clockairapp.settings.time_format": "时间格式",
+ "clockairapp.settings.startup_tab": "启动页面",
+ "clockairapp.settings.show_seconds": "显示秒数",
+ "clockairapp.settings.activate_timer": "计时结束时激活窗口",
+ "clockairapp.settings.time_format.system": "跟随系统",
+ "clockairapp.settings.time_format.24h": "24 小时制",
+ "clockairapp.settings.time_format.12h": "12 小时制",
+ "clockairapp.settings.startup.last": "上次使用",
"poetry.widget.loading_content": "正在加载诗词",
"poetry.widget.loading_author": "加载中",
"poetry.widget.fetch_failed": "诗词获取失败",
@@ -1136,5 +1421,25 @@
"power.sleep_confirm_title": "睡眠确认",
"power.sleep_confirm_message": "确定要让计算机进入睡眠状态吗?",
"power.confirm_yes": "确定",
- "power.confirm_cancel": "取消"
+ "power.confirm_cancel": "取消",
+ "settings.weather.visual_style_header": "天气视觉风格",
+ "settings.weather.visual_style_desc": "选择桌面天气组件使用的图标与组件风格。",
+ "settings.weather.visual_style.GoogleWeatherV4": "Google 天气 v4",
+ "settings.weather.visual_style.Geometric": "几何天气",
+ "settings.weather.visual_style.Breezy": "Breezy Weather",
+ "settings.weather.visual_style.LemonFlutter": "柠檬天气 Flutter",
+ "settings.general.back_to_windows_button_display_header": "回到系统按钮",
+ "settings.general.back_to_windows_button_display_desc": "选择 Dock 按钮显示圆形图标、文字,或同时显示两者。",
+ "settings.general.back_to_windows_button_display.icon_and_text": "图标和文字",
+ "settings.general.back_to_windows_button_display.icon_only": "仅图标",
+ "settings.general.back_to_windows_button_display.text_only": "仅文字",
+ "settings.general.back_to_windows_icon_source_header": "返回按钮图标来源",
+ "settings.general.back_to_windows_icon_source_desc": "选择左侧图标位使用 Fluent 图标,或使用短文字。",
+ "settings.general.back_to_windows_icon_source.fluent_icon": "Fluent 图标",
+ "settings.general.back_to_windows_icon_source.text": "文字图标",
+ "settings.general.back_to_windows_fluent_icon_header": "Fluent 图标",
+ "settings.general.back_to_windows_fluent_icon_desc": "搜索并选择左侧图标位使用的内置 Fluent 图标。",
+ "settings.general.back_to_windows_icon_text_header": "文字图标",
+ "settings.general.back_to_windows_icon_text_desc": "输入最多四个字符,作为左侧图标显示。",
+ "settings.general.back_to_windows_fluent_icon_search_placeholder": "搜索图标"
}
diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
index 7fedb9a..ee7af8c 100644
--- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
@@ -1,5 +1,7 @@
using System.Collections.Generic;
+using System.Text.Json.Serialization;
using LanMountainDesktop.Settings.Core;
+using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Models;
@@ -23,10 +25,14 @@ public sealed class AppSettingsSnapshot
public string ThemeColorMode { get; set; } = "default_neutral";
- public string SystemMaterialMode { get; set; } = "none";
+ public string SystemMaterialMode { get; set; } = "auto";
public string? SelectedWallpaperSeed { get; set; }
+ public string ThemeWallpaperColorSource { get; set; } = "auto";
+
+ public bool UseNativeWallpaperChangeEvents { get; set; } = true;
+
public string ThemeMode { get; set; } = "light";
public string? WallpaperPath { get; set; }
@@ -63,7 +69,7 @@ public sealed class AppSettingsSnapshot
public string WeatherExcludedAlerts { get; set; } = string.Empty;
- public string WeatherIconPackId { get; set; } = "HyperOS3";
+ public string WeatherIconPackId { get; set; } = "GoogleWeatherV4";
public bool WeatherNoTlsRequests { get; set; }
@@ -93,6 +99,8 @@ public sealed class AppSettingsSnapshot
public int UpdateDownloadThreads { get; set; } = 4;
+ public bool ForceUpdateReinstall { get; set; }
+
public string? PendingUpdateInstallerPath { get; set; }
public string? PendingUpdateVersion { get; set; }
@@ -114,6 +122,14 @@ public sealed class AppSettingsSnapshot
public string TaskbarLayoutMode { get; set; } = "BottomFullRowMacStyle";
+ public string BackToWindowsButtonDisplayMode { get; set; } = "IconAndText";
+
+ public string BackToWindowsIconSource { get; set; } = "FluentIcon";
+
+ public string BackToWindowsFluentIconName { get; set; } = "Circle";
+
+ public string BackToWindowsIconText { get; set; } = "○";
+
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
public bool StatusBarClockTransparentBackground { get; set; }
@@ -162,8 +178,14 @@ public sealed class AppSettingsSnapshot
public bool ShowInTaskbar { get; set; } = false;
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public MultiInstanceLaunchBehavior MultiInstanceLaunchBehavior { get; set; } =
+ MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
+
public bool EnableFusedDesktop { get; set; } = false;
+ public bool EnableMainWindowDesktopLayer { get; set; } = false;
+
public List DisabledPluginIds { get; set; } = [];
public bool IsDevModeEnabled { get; set; }
@@ -218,33 +240,38 @@ public sealed class AppSettingsSnapshot
#endregion
- #region Notification Box Settings (消息盒子全局设置)
+ #region Notification Box Settings
///
- /// 启用消息盒子功能(Windows通知监听)
+ /// Enables the system notification inbox component.
///
public bool NotificationBoxEnabled { get; set; } = true;
///
- /// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
+ /// Hides notification details when unread messages are present.
///
public bool NotificationBoxPrivacyMode { get; set; } = false;
///
- /// 被屏蔽的应用列表(不接收这些应用的通知)
+ /// App IDs that should not be collected by the notification box.
///
public List NotificationBoxBlockedApps { get; set; } = [];
///
- /// 历史记录保留天数
+ /// Number of days to retain notification box history.
///
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
///
- /// 最大存储通知数量(防止内存无限增长)
+ /// Maximum number of notifications kept in memory.
///
public int NotificationBoxMaxStoredCount { get; set; } = 500;
+ ///
+ /// Linux capture mode: ProxyDaemon or PassiveMonitor.
+ ///
+ public string NotificationBoxLinuxCaptureMode { get; set; } = "ProxyDaemon";
+
#endregion
public AppSettingsSnapshot Clone()
diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
index 7388e65..cb344ad 100644
--- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
@@ -84,40 +84,40 @@ public sealed class ComponentSettingsSnapshot
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
- #region Notification Box Component Settings (消息盒子组件设置)
+ #region Notification Box Component Settings
///
- /// 组件内最大显示通知数量
+ /// Maximum number of notifications displayed by this component.
///
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
///
- /// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
+ /// Sort order: TimeDesc, TimeAsc, AppGroup.
///
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
///
- /// 是否显示应用图标
+ /// Whether to show app icons.
///
public bool NotificationBoxShowAppIcon { get; set; } = true;
///
- /// 是否显示时间戳
+ /// Whether to show timestamps.
///
public bool NotificationBoxShowTimestamp { get; set; } = true;
///
- /// 时间格式:Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
+ /// Time format: Relative or Absolute.
///
public string NotificationBoxTimeFormat { get; set; } = "Relative";
///
- /// 是否按应用分组显示
+ /// Whether to group notifications by app.
///
public bool NotificationBoxGroupByApp { get; set; } = false;
///
- /// 是否显示清除按钮
+ /// Whether to show the clear button.
///
public bool NotificationBoxShowClearButton { get; set; } = true;
diff --git a/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs b/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs
index df071be..99d4767 100644
--- a/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs
+++ b/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs
@@ -37,6 +37,14 @@ public sealed class FusedDesktopComponentPlacementSnapshot
/// 高度(像素)
///
public double Height { get; set; } = 200;
+
+ public int? GridRow { get; set; }
+
+ public int? GridColumn { get; set; }
+
+ public int? GridWidthCells { get; set; }
+
+ public int? GridHeightCells { get; set; }
///
/// Z-Index(用于控制组件层叠顺序)
@@ -61,6 +69,10 @@ public sealed class FusedDesktopComponentPlacementSnapshot
Y = Y,
Width = Width,
Height = Height,
+ GridRow = GridRow,
+ GridColumn = GridColumn,
+ GridWidthCells = GridWidthCells,
+ GridHeightCells = GridHeightCells,
ZIndex = ZIndex,
IsLocked = IsLocked
};
diff --git a/LanMountainDesktop/Models/MaterialColorModels.cs b/LanMountainDesktop/Models/MaterialColorModels.cs
new file mode 100644
index 0000000..5cfeefb
--- /dev/null
+++ b/LanMountainDesktop/Models/MaterialColorModels.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using Avalonia.Media;
+using LanMountainDesktop.Services;
+using LanMountainDesktop.Shared.Contracts;
+
+namespace LanMountainDesktop.Models;
+
+public enum MaterialColorSourceKind
+{
+ Neutral = 0,
+ CustomSeed = 1,
+ WallpaperAuto = 2,
+ AppWallpaper = 3,
+ SystemWallpaper = 4,
+ Fallback = 5
+}
+
+public sealed record MaterialColorPalette(
+ Color Primary,
+ Color Secondary,
+ Color Accent,
+ Color OnAccent,
+ Color AccentLight1,
+ Color AccentLight2,
+ Color AccentLight3,
+ Color AccentDark1,
+ Color AccentDark2,
+ Color AccentDark3,
+ Color SurfaceBase,
+ Color SurfaceRaised,
+ Color SurfaceOverlay,
+ Color TextPrimary,
+ Color TextSecondary,
+ Color TextMuted,
+ Color TextAccent,
+ Color NavText,
+ Color NavSelectedText,
+ Color NavSelectionIndicator,
+ Color NavItemBackground,
+ Color NavItemHoverBackground,
+ Color NavItemSelectedBackground,
+ Color ToggleOn,
+ Color ToggleOff,
+ Color ToggleBorder);
+
+public sealed record MaterialSurfaceSnapshot(
+ MaterialSurfaceRole Role,
+ Color BackgroundColor,
+ Color BorderColor,
+ double BlurRadius,
+ double Opacity);
+
+public sealed record MaterialColorSnapshot(
+ bool IsNightMode,
+ string ThemeColorMode,
+ string ThemeWallpaperColorSource,
+ MaterialColorSourceKind ColorSourceKind,
+ string ResolvedSeedSource,
+ AppearanceCornerRadiusTokens CornerRadiusTokens,
+ string? UserThemeColor,
+ string? SelectedWallpaperSeed,
+ Color EffectiveSeedColor,
+ Color AccentColor,
+ MonetPalette MonetPalette,
+ MaterialColorPalette Palette,
+ IReadOnlyList WallpaperSeedCandidates,
+ string SystemMaterialMode,
+ IReadOnlyList AvailableSystemMaterialModes,
+ bool CanChangeSystemMaterial,
+ bool UseSystemChrome,
+ string? ResolvedWallpaperPath,
+ bool UseNativeWallpaperChangeEvents,
+ bool NativeWallpaperChangeEventsActive,
+ bool WallpaperPollingActive,
+ IReadOnlyDictionary Surfaces);
diff --git a/LanMountainDesktop/Models/NotificationItem.cs b/LanMountainDesktop/Models/NotificationItem.cs
index 119b067..cee4761 100644
--- a/LanMountainDesktop/Models/NotificationItem.cs
+++ b/LanMountainDesktop/Models/NotificationItem.cs
@@ -3,52 +3,43 @@ using System;
namespace LanMountainDesktop.Models;
///
-/// 通知项数据模型
+/// Notification captured by the desktop notification box.
///
public sealed class NotificationItem
{
- ///
- /// 唯一标识
- ///
public string Id { get; set; } = Guid.NewGuid().ToString();
- ///
- /// 应用ID(如 WeChat, Outlook 等)
- ///
public string AppId { get; set; } = string.Empty;
- ///
- /// 应用名称
- ///
public string AppName { get; set; } = string.Empty;
- ///
- /// 应用图标路径或Base64
- ///
public string? AppIconPath { get; set; }
- ///
- /// 通知标题
- ///
+ public byte[]? AppIconBytes { get; set; }
+
public string Title { get; set; } = string.Empty;
- ///
- /// 通知内容
- ///
public string Content { get; set; } = string.Empty;
- ///
- /// 接收时间
- ///
public DateTime ReceivedTime { get; set; } = DateTime.Now;
- ///
- /// 是否已读
- ///
- public bool IsRead { get; set; } = false;
+ public DateTimeOffset ReceivedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+
+ public bool IsRead { get; set; }
- ///
- /// 原始通知的额外数据(用于点击跳转)
- ///
public string? LaunchArgs { get; set; }
+
+ public string Platform { get; set; } = "Unknown";
+
+ public string? SourceNotificationId { get; set; }
+
+ public string? DesktopEntryId { get; set; }
+
+ public string? Aumid { get; set; }
+
+ public string? LaunchTarget { get; set; }
+
+ public bool CanActivate { get; set; }
+
+ public string CaptureMode { get; set; } = "Unknown";
}
diff --git a/LanMountainDesktop/Models/WeatherDataModels.cs b/LanMountainDesktop/Models/WeatherDataModels.cs
index 6d3afd7..bbefe00 100644
--- a/LanMountainDesktop/Models/WeatherDataModels.cs
+++ b/LanMountainDesktop/Models/WeatherDataModels.cs
@@ -39,6 +39,14 @@ public sealed record WeatherHourlyForecast(
int? WeatherCode,
string? WeatherText);
+public sealed record WeatherAlert(
+ string Title,
+ string? Detail,
+ string? Type,
+ string? Level,
+ DateTimeOffset? PublishedAt,
+ string? IconUri);
+
public sealed record WeatherSnapshot(
string Provider,
string LocationKey,
@@ -49,4 +57,7 @@ public sealed record WeatherSnapshot(
DateTimeOffset? ObservationTime,
WeatherCurrentCondition Current,
IReadOnlyList DailyForecasts,
- IReadOnlyList HourlyForecasts);
+ IReadOnlyList HourlyForecasts)
+{
+ public IReadOnlyList Alerts { get; init; } = Array.Empty();
+}
diff --git a/LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs b/LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
index 7aee12f..969faab 100644
--- a/LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
+++ b/LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
@@ -5,10 +5,24 @@ namespace LanMountainDesktop.Models;
public sealed class WhiteboardNoteSnapshot
{
- public int Version { get; set; } = 1;
+ public int Version { get; set; } = 2;
public DateTimeOffset SavedUtc { get; set; }
+ public DateTimeOffset? ExpiresUtc { get; set; }
+
+ public double CanvasWidth { get; set; }
+
+ public double CanvasHeight { get; set; }
+
+ public string BackgroundColor { get; set; } = "#FFFFFFFF";
+
+ public double ViewportZoom { get; set; } = 1d;
+
+ public double ViewportOffsetX { get; set; }
+
+ public double ViewportOffsetY { get; set; }
+
public List Strokes { get; set; } = [];
public WhiteboardNoteSnapshot Clone()
@@ -29,6 +43,8 @@ public sealed class WhiteboardStrokeSnapshot
public bool IgnorePressure { get; set; } = true;
+ public string? PathSvgData { get; set; }
+
public List Points { get; set; } = [];
public WhiteboardStrokeSnapshot Clone()
diff --git a/LanMountainDesktop/Platform/Windows/ChromePatchState.cs b/LanMountainDesktop/Platform/Windows/ChromePatchState.cs
new file mode 100644
index 0000000..7ee09a6
--- /dev/null
+++ b/LanMountainDesktop/Platform/Windows/ChromePatchState.cs
@@ -0,0 +1,6 @@
+namespace LanMountainDesktop.Platform.Windows;
+
+internal static class ChromePatchState
+{
+ public static bool UseSystemChrome { get; set; }
+}
diff --git a/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs b/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs
new file mode 100644
index 0000000..6cecf3d
--- /dev/null
+++ b/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs
@@ -0,0 +1,12 @@
+using HarmonyLib;
+
+namespace LanMountainDesktop.Platform.Windows;
+
+internal static class PatcherEntrance
+{
+ public static void InstallPatchers()
+ {
+ var harmony = new Harmony("dev.lanmountain.desktop.patchers");
+ harmony.PatchAll(typeof(PatcherEntrance).Assembly);
+ }
+}
diff --git a/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs b/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs
new file mode 100644
index 0000000..0941f4e
--- /dev/null
+++ b/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs
@@ -0,0 +1,25 @@
+using System.Runtime.CompilerServices;
+using Avalonia;
+using Avalonia.Controls;
+using FluentAvalonia.UI.Windowing;
+using LanMountainDesktop.Platform.Windows;
+using HarmonyLib;
+
+namespace LanMountainDesktop.Platform.Windows.Patches;
+
+[HarmonyPatch(typeof(FAAppWindow), "InitializeAppWindow")]
+internal class AppWindowInitializeAppWindowPatcher
+{
+ [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_PseudoClasses")]
+ private static extern IPseudoClasses GetPseudoClasses(StyledElement window);
+
+ [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_IsWindows")]
+ private static extern void SetIsWindowsProperty(FAAppWindow window, bool v);
+
+ static void Postfix(FAAppWindow __instance)
+ {
+ if (!ChromePatchState.UseSystemChrome) return;
+ GetPseudoClasses(__instance).Remove(":windows");
+ SetIsWindowsProperty(__instance, false);
+ }
+}
diff --git a/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs b/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs
new file mode 100644
index 0000000..e67b3f8
--- /dev/null
+++ b/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs
@@ -0,0 +1,21 @@
+using FluentAvalonia.UI.Windowing;
+using HarmonyLib;
+using LanMountainDesktop.Platform.Windows;
+
+namespace LanMountainDesktop.Platform.Windows.Patches;
+
+[HarmonyPatch]
+internal class Win32WindowManagerConstructorPatcher
+{
+ [HarmonyTargetMethod]
+ static System.Reflection.MethodBase TargetMethod()
+ {
+ var type = AccessTools.TypeByName("FluentAvalonia.UI.Windowing.Win32WindowManager");
+ return AccessTools.Constructor(type!, [typeof(FAAppWindow)]);
+ }
+
+ static bool Prefix(FAAppWindow window)
+ {
+ return !ChromePatchState.UseSystemChrome;
+ }
+}
diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs
index 1e1a50d..316c9c0 100644
--- a/LanMountainDesktop/Program.cs
+++ b/LanMountainDesktop/Program.cs
@@ -1,15 +1,11 @@
using System;
-using System.Diagnostics;
-using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
-using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Services.Settings;
-using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop;
@@ -24,43 +20,6 @@ public sealed class Program
AppDataPathProvider.Initialize(args);
DevPluginOptions.Parse(args);
RegisterGlobalExceptionLogging();
- var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
-
- using var singleInstance = AcquireSingleInstance(restartParentProcessId);
- if (!singleInstance.IsPrimaryInstance)
- {
- if (restartParentProcessId is not null)
- {
- AppLogger.Warn(
- "Startup",
- $"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
- ReportLauncherStageBeforeExit(StartupStage.ActivationFailed, "Restart relaunch could not acquire the single-instance lock.");
- Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
- return;
- }
-
- var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
- if (activationAcknowledged)
- {
- AppLogger.Info(
- "Startup",
- $"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
- ReportLauncherStageBeforeExit(StartupStage.ActivationRedirected, "Secondary launch forwarded to the primary instance.");
- Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
- }
- else
- {
- AppLogger.Warn(
- "Startup",
- $"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
- ReportLauncherStageBeforeExit(
- StartupStage.ActivationFailed,
- $"Secondary launch failed to activate the primary instance. Reason='{failureReason ?? "unknown"}'.");
- Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
- }
-
- return;
- }
DesktopBootstrap.InitializeStartupServices(
InitializeTelemetryIdentity,
@@ -76,17 +35,8 @@ 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);
AppLogger.Info("Startup", "Application exited normally.");
}
@@ -95,10 +45,6 @@ public sealed class Program
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
throw;
}
- finally
- {
- App.CurrentSingleInstanceService = null;
- }
}
public static AppBuilder BuildAvaloniaApp()
@@ -147,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
@@ -198,23 +109,46 @@ public sealed class Program
}
}
- private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
+ private static void LoadChromePatchState()
{
try
{
- using var process = Process.GetProcessById(processId);
- var remaining = deadlineUtc - DateTime.UtcNow;
- if (remaining > TimeSpan.Zero)
+ var snapshot = HostSettingsFacadeProvider.GetOrCreate()
+ .Settings
+ .LoadSnapshot(LanMountainDesktop.PluginSdk.SettingsScope.App);
+ if (OperatingSystem.IsWindows())
{
- process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds));
+ LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome = snapshot.UseSystemChrome;
}
}
- catch (ArgumentException)
- {
- }
catch (Exception ex)
{
- AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex);
+ AppLogger.Warn("Startup", "Failed to load chrome patch state. Falling back to FA chrome.", ex);
+ }
+ }
+
+ private static void InstallChromePatchersIfNeeded()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture;
+ if (arch != System.Runtime.InteropServices.Architecture.X64 &&
+ arch != System.Runtime.InteropServices.Architecture.X86)
+ {
+ return;
+ }
+
+ try
+ {
+ LanMountainDesktop.Platform.Windows.PatcherEntrance.InstallPatchers();
+ AppLogger.Info("Startup", $"Chrome patchers installed. UseSystemChrome={LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome}.");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Startup", "Failed to install chrome patchers.", ex);
}
}
@@ -262,35 +196,6 @@ public sealed class Program
};
}
- private static void ReportLauncherStageBeforeExit(StartupStage stage, string message)
- {
- if (!LauncherIpcClient.IsLaunchedByLauncher())
- {
- return;
- }
-
- try
- {
- using var launcherIpcClient = new LauncherIpcClient();
- var connected = launcherIpcClient.ConnectAsync().GetAwaiter().GetResult();
- if (!connected)
- {
- return;
- }
-
- launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
- {
- Stage = stage,
- ProgressPercent = 100,
- Message = message
- }).GetAwaiter().GetResult();
- }
- catch (Exception ex)
- {
- AppLogger.Warn("LauncherIpc", $"Failed to report early launcher stage '{stage}'.", ex);
- }
- }
-
private static void InitializeTelemetryIdentity()
{
try
diff --git a/LanMountainDesktop/Services/AirAppLauncherService.cs b/LanMountainDesktop/Services/AirAppLauncherService.cs
new file mode 100644
index 0000000..716ca13
--- /dev/null
+++ b/LanMountainDesktop/Services/AirAppLauncherService.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading.Tasks;
+using LanMountainDesktop.ComponentSystem;
+using LanMountainDesktop.Shared.IPC;
+using LanMountainDesktop.Shared.IPC.Abstractions.Services;
+
+namespace LanMountainDesktop.Services;
+
+public interface IAirAppLauncherService
+{
+ void OpenWorldClock(string? sourcePlacementId);
+
+ void OpenWorldClock(string sourceComponentId, string? sourcePlacementId);
+
+ void OpenWhiteboard(string componentId, string? sourcePlacementId);
+}
+
+internal sealed class AirAppLauncherService : IAirAppLauncherService
+{
+ public const string WorldClockAppId = "world-clock";
+ public const string WhiteboardAppId = "whiteboard";
+
+ private const int LauncherIpcRetryCount = 4;
+
+ public void OpenWorldClock(string? sourcePlacementId)
+ {
+ OpenWorldClock(BuiltInComponentIds.DesktopWorldClock, sourcePlacementId);
+ }
+
+ public void OpenWorldClock(string sourceComponentId, string? sourcePlacementId)
+ {
+ var componentId = string.IsNullOrWhiteSpace(sourceComponentId)
+ ? BuiltInComponentIds.DesktopWorldClock
+ : sourceComponentId.Trim();
+ AppLogger.Info(
+ "AirAppLauncher",
+ $"World Clock Air APP requested. ComponentId='{componentId}'; PlacementId='{sourcePlacementId ?? string.Empty}'.");
+ _ = OpenAsync(WorldClockAppId, componentId, sourcePlacementId);
+ }
+
+ public void OpenWhiteboard(string componentId, string? sourcePlacementId)
+ {
+ AppLogger.Info(
+ "AirAppLauncher",
+ $"Whiteboard Air APP requested. ComponentId='{componentId}'; PlacementId='{sourcePlacementId ?? string.Empty}'.");
+ _ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId);
+ }
+
+ internal static AirAppOpenRequest BuildOpenRequest(
+ string appId,
+ string? sourceComponentId,
+ string? sourcePlacementId,
+ int requesterProcessId)
+ {
+ return new AirAppOpenRequest(
+ appId.Trim(),
+ string.IsNullOrWhiteSpace(sourceComponentId) ? null : sourceComponentId.Trim(),
+ string.IsNullOrWhiteSpace(sourcePlacementId) ? null : sourcePlacementId.Trim(),
+ requesterProcessId);
+ }
+
+ internal static string BuildSingleInstanceKey(string appId, string? sourceComponentId, string? sourcePlacementId)
+ {
+ var normalizedAppId = string.IsNullOrWhiteSpace(appId) ? "unknown" : appId.Trim();
+ if (string.Equals(normalizedAppId, WorldClockAppId, StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{normalizedAppId}:clock-suite:global";
+ }
+
+ var normalizedComponentId = string.IsNullOrWhiteSpace(sourceComponentId) ? "none" : sourceComponentId.Trim();
+ var normalizedPlacementId = string.IsNullOrWhiteSpace(sourcePlacementId) ? "none" : sourcePlacementId.Trim();
+ return $"{normalizedAppId}:{normalizedComponentId}:{normalizedPlacementId}";
+ }
+
+ private static async Task OpenAsync(string appId, string sourceComponentId, string? sourcePlacementId)
+ {
+ var request = BuildOpenRequest(appId, sourceComponentId, sourcePlacementId, Environment.ProcessId);
+ try
+ {
+ var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
+ if (result.Accepted)
+ {
+ AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
+ return;
+ }
+
+ AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex);
+ }
+ }
+
+ private static async Task SendOpenRequestAsync(AirAppOpenRequest request)
+ {
+ Exception? lastException = null;
+ for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++)
+ {
+ try
+ {
+ using var client = new LanMountainDesktopIpcClient();
+ await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false);
+ var proxy = client.CreateProxy();
+ return await proxy.OpenAsync(request).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ lastException = ex;
+ if (attempt == 1)
+ {
+ AppLogger.Warn(
+ "AirAppLauncher",
+ $"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.",
+ ex);
+ TryStartLauncher();
+ }
+
+ await Task.Delay(250 * attempt).ConfigureAwait(false);
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.",
+ lastException);
+ }
+
+ internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = launcherPath,
+ WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
+ UseShellExecute = false
+ };
+ startInfo.ArgumentList.Add("air-app-broker");
+ startInfo.ArgumentList.Add("--requester-pid");
+ startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ return startInfo;
+ }
+
+ private static void TryStartLauncher()
+ {
+ try
+ {
+ var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
+ if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
+ {
+ AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found.");
+ return;
+ }
+
+ var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId);
+ _ = Process.Start(startInfo);
+ AppLogger.Info(
+ "AirAppLauncher",
+ $"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex);
+ }
+ }
+}
+
+public static class AirAppLauncherServiceProvider
+{
+ private static readonly object Gate = new();
+ private static IAirAppLauncherService? _instance;
+
+ public static IAirAppLauncherService GetOrCreate()
+ {
+ lock (Gate)
+ {
+ _instance ??= new AirAppLauncherService();
+ return _instance;
+ }
+ }
+}
diff --git a/LanMountainDesktop/Services/AppDataPathProvider.cs b/LanMountainDesktop/Services/AppDataPathProvider.cs
index 55d6f5a..e1b7f12 100644
--- a/LanMountainDesktop/Services/AppDataPathProvider.cs
+++ b/LanMountainDesktop/Services/AppDataPathProvider.cs
@@ -53,12 +53,20 @@ public static class AppDataPathProvider
private static string? ResolveDataRootFromArgs(string[] args)
{
const string prefix = "--data-root=";
- foreach (var arg in args)
+ for (var index = 0; index < args.Length; index++)
{
+ var arg = args[index];
if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return arg[prefix.Length..];
}
+
+ if (string.Equals(arg, "--data-root", StringComparison.OrdinalIgnoreCase) &&
+ index + 1 < args.Length &&
+ !args[index + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ return args[index + 1];
+ }
}
return null;
diff --git a/LanMountainDesktop/Services/AppearanceThemeService.cs b/LanMountainDesktop/Services/AppearanceThemeService.cs
index 805c365..83f975a 100644
--- a/LanMountainDesktop/Services/AppearanceThemeService.cs
+++ b/LanMountainDesktop/Services/AppearanceThemeService.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
+using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
@@ -55,7 +56,9 @@ public sealed record AppearanceThemeSnapshot(
IReadOnlyList AvailableSystemMaterialModes,
bool CanChangeSystemMaterial,
bool UseSystemChrome,
- string? ResolvedWallpaperPath);
+ string? ResolvedWallpaperPath,
+ string ThemeWallpaperColorSource = ThemeAppearanceValues.WallpaperColorSourceAuto,
+ bool UseNativeWallpaperChangeEvents = true);
public interface IAppearanceThemeService
{
@@ -72,13 +75,6 @@ public interface IAppearanceThemeService
void ApplyWindowMaterial(Window window, MaterialSurfaceRole role);
}
-internal interface ISystemWallpaperService
-{
- bool IsSupported { get; }
-
- string? GetWallpaperPath();
-}
-
internal interface IWindowMaterialService
{
IReadOnlyList GetAvailableModes();
@@ -93,905 +89,91 @@ internal interface IMaterialSurfaceService
AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role);
}
-internal readonly record struct WallpaperSeedSourceDescriptor(
- string SourceKind,
- string SourceKey,
- string? ResolvedWallpaperPath,
- string? FilePath,
- Color? SolidColor);
-
-internal sealed record WallpaperSeedExtractionResult(
- string SourceKind,
- string SourceKey,
- string? ResolvedWallpaperPath,
- IReadOnlyList SeedCandidates);
-
-internal readonly record struct WallpaperPaletteResolution(
- MonetPalette Palette,
- IReadOnlyList SeedCandidates,
- string ResolvedSeedSource,
- Color EffectiveSeedColor,
- string? ResolvedWallpaperPath);
-
-internal sealed class SystemWallpaperService : ISystemWallpaperService
-{
- public bool IsSupported => OperatingSystem.IsWindows();
-
- public string? GetWallpaperPath()
- {
- if (!OperatingSystem.IsWindows())
- {
- return null;
- }
-
- try
- {
- using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop", writable: false);
- var wallpaperPath = key?.GetValue("WallPaper") as string;
- return string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath)
- ? null
- : wallpaperPath;
- }
- catch (Exception ex)
- {
- AppLogger.Warn("Appearance.SystemWallpaper", "Failed to resolve the current system wallpaper path.", ex);
- return null;
- }
- }
-}
-
-internal sealed class WindowMaterialService : IWindowMaterialService
-{
- private const int Windows11Build = 22000;
- private const int Windows11_24H2Build = 26100;
-
- public bool CanChangeMode => GetSupportProfile() == WindowMaterialSupportProfile.FullSwitching;
-
- public IReadOnlyList GetAvailableModes()
- {
- return GetSupportProfile() switch
- {
- WindowMaterialSupportProfile.FullSwitching =>
- [
- ThemeAppearanceValues.MaterialNone,
- ThemeAppearanceValues.MaterialMica,
- ThemeAppearanceValues.MaterialAcrylic
- ],
- WindowMaterialSupportProfile.FixedMica =>
- [
- ThemeAppearanceValues.MaterialNone,
- ThemeAppearanceValues.MaterialMica
- ],
- WindowMaterialSupportProfile.FixedAcrylic =>
- [
- ThemeAppearanceValues.MaterialNone,
- ThemeAppearanceValues.MaterialAcrylic
- ],
- _ =>
- [
- ThemeAppearanceValues.MaterialNone
- ]
- };
- }
-
- public void Apply(Window window, string materialMode)
- {
- ArgumentNullException.ThrowIfNull(window);
-
- var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
-
- if (normalizedMode == ThemeAppearanceValues.MaterialNone)
- {
- window.Background = Brushes.White;
- window.TransparencyLevelHint = [WindowTransparencyLevel.None];
- return;
- }
-
- window.Background = Brushes.Transparent;
-
- if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
- {
- window.TransparencyLevelHint =
- [
- WindowTransparencyLevel.None
- ];
- return;
- }
-
- window.TransparencyLevelHint = normalizedMode switch
- {
- ThemeAppearanceValues.MaterialMica =>
- [
- WindowTransparencyLevel.Mica,
- WindowTransparencyLevel.Blur,
- WindowTransparencyLevel.None
- ],
- ThemeAppearanceValues.MaterialAcrylic =>
- [
- WindowTransparencyLevel.AcrylicBlur,
- WindowTransparencyLevel.Blur,
- WindowTransparencyLevel.None
- ],
- _ =>
- [
- WindowTransparencyLevel.None
- ]
- };
- }
-
- private static bool IsTransparencyEnabled()
- {
- if (!OperatingSystem.IsWindows())
- {
- return false;
- }
-
- try
- {
- using var key = Registry.CurrentUser.OpenSubKey(
- @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
- writable: false);
- var value = key?.GetValue("EnableTransparency");
- return value switch
- {
- int intValue => intValue != 0,
- byte byteValue => byteValue != 0,
- _ => true
- };
- }
- catch
- {
- return true;
- }
- }
-
- private static WindowMaterialSupportProfile GetSupportProfile()
- {
- if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
- {
- return WindowMaterialSupportProfile.NoneOnly;
- }
-
- if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11_24H2Build))
- {
- return WindowMaterialSupportProfile.FullSwitching;
- }
-
- if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11Build))
- {
- return WindowMaterialSupportProfile.FixedMica;
- }
-
- if (OperatingSystem.IsWindowsVersionAtLeast(10, 0))
- {
- return WindowMaterialSupportProfile.FixedAcrylic;
- }
-
- return WindowMaterialSupportProfile.NoneOnly;
- }
-
- private enum WindowMaterialSupportProfile
- {
- NoneOnly = 0,
- FixedMica = 1,
- FixedAcrylic = 2,
- FullSwitching = 3
- }
-}
-
-internal sealed class MaterialSurfaceService : IMaterialSurfaceService
-{
- public AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role)
- {
- var monetPalette = context.MonetPalette;
- var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
- var primary = context.UseNeutralSurfaces
- ? context.AccentColor
- : monetPalette?.Primary ?? (monetColors.Length > 0 ? monetColors[0] : context.AccentColor);
- var secondary = monetPalette?.Secondary
- ?? (monetColors.Length > 1
- ? monetColors[1]
- : ColorMath.Blend(primary, Color.Parse("#FFFFFFFF"), 0.14));
- var neutralPrimary = monetPalette?.Neutral
- ?? (monetColors.Length > 3
- ? monetColors[3]
- : ResolveNeutralBase(context.IsNightMode, role));
- var neutralSecondary = monetPalette?.NeutralVariant
- ?? (monetColors.Length > 4
- ? monetColors[4]
- : ResolveLiftBase(context.IsNightMode, role));
- var materialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(context.SystemMaterialMode);
-
- var (tintStrength, liftStrength, alpha, blurRadius) = ResolveModeParameters(materialMode, role, context.IsNightMode);
- var neutralBase = ResolveNeutralBase(context.IsNightMode, role);
- var neutralLift = ResolveLiftBase(context.IsNightMode, role);
- var isDockLike = role is MaterialSurfaceRole.DockBackground;
- var isComponentLike = role is MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost;
- var baseMix = isDockLike ? 0.88 : isComponentLike ? 0.74 : 0.82;
- var liftMix = isDockLike ? 0.58 : isComponentLike ? 0.34 : 0.46;
- var neutralMix = isDockLike ? 0.22 : 0.16;
-
- var background = ColorMath.Blend(neutralBase, neutralPrimary, baseMix);
- background = ColorMath.Blend(background, neutralLift, liftMix);
- background = ColorMath.Blend(background, neutralSecondary, neutralMix);
- if (!context.UseNeutralSurfaces)
- {
- background = ColorMath.Blend(background, primary, tintStrength);
- background = ColorMath.Blend(background, secondary, liftStrength);
- }
-
- if (isDockLike && !context.IsNightMode)
- {
- background = ColorMath.Blend(background, Color.Parse("#FFFFFFFF"), 0.12);
- }
-
- background = Color.FromArgb(alpha, background.R, background.G, background.B);
-
- var borderSeed = context.IsNightMode
- ? ColorMath.Blend(neutralSecondary, Color.Parse("#FFFFFFFF"), 0.16)
- : ColorMath.Blend(neutralSecondary, Color.Parse("#FF334155"), 0.08);
- if (!context.UseNeutralSurfaces && !isComponentLike)
- {
- borderSeed = ColorMath.Blend(borderSeed, primary, 0.08);
- }
-
- var borderAlpha = role switch
- {
- MaterialSurfaceRole.DockBackground => context.IsNightMode ? (byte)0x34 : (byte)0x18,
- MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost =>
- context.IsNightMode ? (byte)0x18 : (byte)0x10,
- MaterialSurfaceRole.StatusBarBackground => (byte)0x00,
- _ => context.IsNightMode ? (byte)0x26 : (byte)0x16
- };
- var border = ColorMath.WithAlpha(borderSeed, borderAlpha);
-
- return new AppearanceMaterialSurface(background, border, blurRadius, 1.0);
- }
-
- private static (double TintStrength, double LiftStrength, byte Alpha, double BlurRadius) ResolveModeParameters(
- string materialMode,
- MaterialSurfaceRole role,
- bool isNightMode)
- {
- var isOverlay = role is MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel;
- return materialMode switch
- {
- ThemeAppearanceValues.MaterialAcrylic => (
- isOverlay ? 0.30 : 0.20,
- isOverlay ? 0.22 : 0.14,
- isNightMode ? (byte)0xD8 : (byte)0xE0,
- isOverlay ? 36 : 28),
- ThemeAppearanceValues.MaterialMica => (
- isOverlay ? 0.20 : 0.14,
- isOverlay ? 0.12 : 0.08,
- isNightMode ? (byte)0xEC : (byte)0xF2,
- isOverlay ? 28 : 20),
- _ => (
- isOverlay ? 0.12 : 0.08,
- isOverlay ? 0.08 : 0.05,
- (byte)0xFF,
- 0)
- };
- }
-
- private static Color ResolveNeutralBase(bool isNightMode, MaterialSurfaceRole role)
- {
- return role switch
- {
- MaterialSurfaceRole.WindowBackground => isNightMode ? Color.Parse("#FF0A0F16") : Color.Parse("#FFF7F8FA"),
- MaterialSurfaceRole.SettingsWindowBackground => isNightMode ? Color.Parse("#FF0C121A") : Color.Parse("#FFF8FAFC"),
- MaterialSurfaceRole.DockBackground => isNightMode ? Color.Parse("#FF111A24") : Color.Parse("#FFFAFBFD"),
- MaterialSurfaceRole.StatusBarBackground => isNightMode ? Color.Parse("#FF101720") : Color.Parse("#FFF9FBFE"),
- MaterialSurfaceRole.StatusBarComponentHost => isNightMode ? Color.Parse("#FF111A23") : Color.Parse("#FFFCFDFE"),
- MaterialSurfaceRole.OverlayPanel => isNightMode ? Color.Parse("#FF131C27") : Color.Parse("#FFF4F7FB"),
- _ => isNightMode ? Color.Parse("#FF121B26") : Color.Parse("#FFFDFEFF")
- };
- }
-
- private static Color ResolveLiftBase(bool isNightMode, MaterialSurfaceRole role)
- {
- return role switch
- {
- MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel =>
- isNightMode ? Color.Parse("#FF1B2633") : Color.Parse("#FFFFFFFF"),
- _ => isNightMode ? Color.Parse("#FF17212D") : Color.Parse("#FFFFFFFF")
- };
- }
-}
-
internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposable
{
- private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
- private static readonly Color NeutralFallbackSeedColor = Color.Parse("#FF8A8A8A");
- private readonly ISettingsFacadeService _settingsFacade;
- private readonly ISystemWallpaperService _systemWallpaperService;
- private readonly IWindowMaterialService _windowMaterialService;
- private readonly IMaterialSurfaceService _materialSurfaceService;
- private readonly MonetColorService _monetColorService = new();
- private readonly string _liveThemeColorMode;
- private readonly string _liveSystemMaterialMode;
- private readonly string? _liveSelectedWallpaperSeed;
- private readonly object _paletteGate = new();
- private readonly Dictionary _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase);
- private readonly HashSet _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase);
+ private readonly MaterialColorService _materialColorService;
- public AppearanceThemeService(
- ISettingsFacadeService settingsFacade,
- ISystemWallpaperService systemWallpaperService,
- IWindowMaterialService windowMaterialService,
- IMaterialSurfaceService materialSurfaceService)
+ public AppearanceThemeService(MaterialColorService materialColorService)
{
- _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
- _systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService));
- _windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
- _materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
- var initialThemeState = _settingsFacade.Theme.Get();
- _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
- initialThemeState.ThemeColorMode,
- initialThemeState.ThemeColor);
- _liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode);
- _liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed;
- _settingsFacade.Settings.Changed += OnSettingsChanged;
+ _materialColorService = materialColorService ?? throw new ArgumentNullException(nameof(materialColorService));
+ _materialColorService.AppearanceThemeChanged += OnAppearanceThemeChanged;
}
public event EventHandler? Changed;
public AppearanceThemeSnapshot GetCurrent()
{
- return BuildCurrentSnapshot(queueWallpaperPaletteBuild: true);
+ return _materialColorService.GetCurrent();
}
public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState)
{
- ArgumentNullException.ThrowIfNull(pendingState);
-
- var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
- pendingState.ThemeColorMode,
- pendingState.ThemeColor);
- var normalizedSystemMaterialMode = ResolveSupportedMaterialMode(pendingState.SystemMaterialMode);
- return BuildSnapshot(
- pendingState with
- {
- ThemeColorMode = normalizedThemeColorMode,
- SystemMaterialMode = normalizedSystemMaterialMode
- },
- normalizedThemeColorMode,
- normalizedSystemMaterialMode,
- pendingState.SelectedWallpaperSeed,
- queueWallpaperPaletteBuild: true);
+ return _materialColorService.BuildPreview(pendingState);
}
public void ApplyThemeResources(IResourceDictionary resources)
{
- ArgumentNullException.ThrowIfNull(resources);
-
- var snapshot = GetCurrent();
- var context = CreateThemeContext(snapshot);
- ThemeColorSystemService.ApplyThemeResources(resources, context);
- GlassEffectService.ApplyGlassResources(resources, context);
- resources["DesignCornerRadiusMicro"] = snapshot.CornerRadiusTokens.Micro;
- resources["DesignCornerRadiusXs"] = snapshot.CornerRadiusTokens.Xs;
- resources["DesignCornerRadiusSm"] = snapshot.CornerRadiusTokens.Sm;
- resources["DesignCornerRadiusMd"] = snapshot.CornerRadiusTokens.Md;
- resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg;
- resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl;
- resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island;
- resources["DesignCornerRadiusComponent"] = snapshot.CornerRadiusTokens.Component;
+ _materialColorService.ApplyThemeResources(resources);
}
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
{
- var snapshot = GetCurrent();
- return _materialSurfaceService.GetSurface(CreateThemeContext(snapshot), role);
+ return _materialColorService.GetMaterialSurface(role);
}
public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)
{
- ArgumentNullException.ThrowIfNull(window);
-
- // Avoid hot-switching real backdrops on already-visible windows. This has been
- // a stability hotspot when users flip theme source/material at runtime.
- if (window.IsVisible)
- {
- return;
- }
-
- var snapshot = GetCurrent();
-
- try
- {
- _windowMaterialService.Apply(window, snapshot.SystemMaterialMode);
- }
- catch (Exception ex)
- {
- AppLogger.Warn(
- "Appearance.WindowMaterial",
- $"Failed to apply window material '{snapshot.SystemMaterialMode}'. Falling back to none.",
- ex);
- _windowMaterialService.Apply(window, ThemeAppearanceValues.MaterialNone);
- }
+ _materialColorService.ApplyWindowMaterial(window, role);
}
public void Dispose()
{
- _settingsFacade.Settings.Changed -= OnSettingsChanged;
+ _materialColorService.AppearanceThemeChanged -= OnAppearanceThemeChanged;
}
- private AppearanceThemeSnapshot BuildCurrentSnapshot(bool queueWallpaperPaletteBuild)
- {
- var themeState = _settingsFacade.Theme.Get();
- return BuildSnapshot(
- themeState,
- _liveThemeColorMode,
- _liveSystemMaterialMode,
- _liveSelectedWallpaperSeed,
- queueWallpaperPaletteBuild);
- }
-
- private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
+ private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot snapshot)
{
_ = sender;
-
- if (e.Scope != SettingsScope.App)
- {
- return;
- }
-
- var changedKeys = e.ChangedKeys?.ToArray();
- var refreshAll = changedKeys is null || changedKeys.Length == 0;
- var respondsToThemeColor = string.Equals(
- _liveThemeColorMode,
- ThemeAppearanceValues.ColorModeSeedMonet,
- StringComparison.OrdinalIgnoreCase);
- var respondsToWallpaper = string.Equals(
- _liveThemeColorMode,
- ThemeAppearanceValues.ColorModeWallpaperMonet,
- StringComparison.OrdinalIgnoreCase);
-
- if (!refreshAll &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) &&
- !(respondsToThemeColor &&
- changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
- !(respondsToWallpaper &&
- (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))))
- {
- return;
- }
-
- RaiseChanged(queueWallpaperPaletteBuild: true);
- }
-
- private AppearanceThemeSnapshot BuildSnapshot(
- ThemeAppearanceSettingsState themeState,
- string themeColorMode,
- string systemMaterialMode,
- string? selectedWallpaperSeed,
- bool queueWallpaperPaletteBuild)
- {
- var availableModes = _windowMaterialService.GetAvailableModes();
- var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle);
- var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle);
- MonetPalette palette;
- IReadOnlyList wallpaperSeedCandidates;
- Color effectiveSeedColor;
- string resolvedSeedSource;
- string? resolvedWallpaperPath;
-
- if (string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase))
- {
- var wallpaperState = _settingsFacade.Wallpaper.Get();
- var wallpaperResolution = ResolveWallpaperPalette(
- themeState.IsNightMode,
- wallpaperState,
- selectedWallpaperSeed,
- queueWallpaperPaletteBuild);
- palette = wallpaperResolution.Palette;
- wallpaperSeedCandidates = wallpaperResolution.SeedCandidates;
- effectiveSeedColor = wallpaperResolution.EffectiveSeedColor;
- resolvedSeedSource = wallpaperResolution.ResolvedSeedSource;
- resolvedWallpaperPath = wallpaperResolution.ResolvedWallpaperPath;
- }
- else
- {
- var preferredSeedColor = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase)
- ? themeState.ThemeColor
- : null;
- palette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, null, preferredSeedColor);
- wallpaperSeedCandidates = [];
- effectiveSeedColor = ResolveEffectiveSeedColor(themeColorMode, themeState.ThemeColor, palette);
- resolvedSeedSource = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)
- ? "neutral"
- : "user_color";
- resolvedWallpaperPath = null;
- }
-
- return new AppearanceThemeSnapshot(
- themeState.IsNightMode,
- themeColorMode,
- themeState.ThemeColor,
- selectedWallpaperSeed,
- cornerRadiusStyle,
- cornerRadiusTokens,
- resolvedSeedSource,
- palette,
- ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette),
- effectiveSeedColor,
- wallpaperSeedCandidates,
- systemMaterialMode,
- availableModes,
- _windowMaterialService.CanChangeMode,
- themeState.UseSystemChrome,
- resolvedWallpaperPath);
- }
-
- private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot)
- {
- return new ThemeColorContext(
- snapshot.AccentColor,
- IsLightBackground: !snapshot.IsNightMode,
- IsLightNavBackground: !snapshot.IsNightMode,
- IsNightMode: snapshot.IsNightMode,
- MonetPalette: snapshot.MonetPalette,
- MonetColors: snapshot.MonetPalette.MonetColors,
- UseNeutralSurfaces: snapshot.ThemeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral,
- SystemMaterialMode: snapshot.SystemMaterialMode);
- }
-
- private string ResolveSupportedMaterialMode(string? requestedMode)
- {
- var normalized = ThemeAppearanceValues.NormalizeSystemMaterialMode(requestedMode);
- var availableModes = _windowMaterialService.GetAvailableModes();
- return availableModes.Contains(normalized, StringComparer.OrdinalIgnoreCase)
- ? normalized
- : ThemeAppearanceValues.MaterialNone;
- }
-
- private WallpaperPaletteResolution ResolveWallpaperPalette(
- bool nightMode,
- WallpaperSettingsState wallpaperState,
- string? selectedWallpaperSeed,
- bool queueWallpaperPaletteBuild)
- {
- var source = ResolveWallpaperSeedSource(wallpaperState);
- if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase))
- {
- return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath);
- }
-
- if (string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase))
- {
- var candidates = source.SolidColor is { } solidColor
- ? new[] { solidColor }
- : [];
- return BuildWallpaperPaletteResolution(nightMode, source, candidates, selectedWallpaperSeed);
- }
-
- lock (_paletteGate)
- {
- if (_wallpaperSeedCache.TryGetValue(source.SourceKey, out var cachedSeedResult))
- {
- if (cachedSeedResult.SeedCandidates.Count > 0)
- {
- return BuildWallpaperPaletteResolution(
- nightMode,
- source with
- {
- SourceKind = cachedSeedResult.SourceKind,
- ResolvedWallpaperPath = cachedSeedResult.ResolvedWallpaperPath
- },
- cachedSeedResult.SeedCandidates,
- selectedWallpaperSeed);
- }
-
- return BuildFallbackWallpaperPaletteResolution(nightMode, cachedSeedResult.ResolvedWallpaperPath);
- }
- }
-
- if (queueWallpaperPaletteBuild)
- {
- QueueWallpaperSeedExtraction(source);
- }
-
- return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath);
- }
-
- private static Color ResolveAccentColor(
- string themeColorMode,
- string? colorText,
- MonetPalette monetPalette)
- {
- if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral)
- {
- return DefaultAccentColor;
- }
-
- if (monetPalette.Primary.A > 0)
- {
- return monetPalette.Primary;
- }
-
- if (!string.IsNullOrWhiteSpace(colorText) && Color.TryParse(colorText, out var parsedColor))
- {
- return parsedColor;
- }
-
- return DefaultAccentColor;
- }
-
- private static Color ResolveEffectiveSeedColor(
- string themeColorMode,
- string? userThemeColor,
- MonetPalette monetPalette)
- {
- if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral)
- {
- return DefaultAccentColor;
- }
-
- if (themeColorMode == ThemeAppearanceValues.ColorModeSeedMonet &&
- !string.IsNullOrWhiteSpace(userThemeColor) &&
- Color.TryParse(userThemeColor, out var parsedColor))
- {
- return parsedColor;
- }
-
- return monetPalette.Seed;
- }
-
- private WallpaperPaletteResolution BuildWallpaperPaletteResolution(
- bool nightMode,
- WallpaperSeedSourceDescriptor source,
- IReadOnlyList seedCandidates,
- string? selectedWallpaperSeed)
- {
- var validatedSeed = ResolveSelectedWallpaperSeed(seedCandidates, selectedWallpaperSeed);
- var palette = _monetColorService.BuildPaletteFromSeedCandidates(seedCandidates, nightMode, validatedSeed);
- return new WallpaperPaletteResolution(
- palette,
- seedCandidates,
- source.SourceKind,
- palette.Seed,
- source.ResolvedWallpaperPath);
- }
-
- private WallpaperPaletteResolution BuildFallbackWallpaperPaletteResolution(bool nightMode, string? resolvedWallpaperPath)
- {
- var palette = _monetColorService.BuildPaletteFromSeedCandidates([], nightMode, NeutralFallbackSeedColor);
- return new WallpaperPaletteResolution(
- palette,
- [],
- "fallback",
- palette.Seed,
- resolvedWallpaperPath);
- }
-
- private void QueueWallpaperSeedExtraction(WallpaperSeedSourceDescriptor source)
- {
- if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase))
- {
- return;
- }
-
- lock (_paletteGate)
- {
- if (_pendingWallpaperSeedKeys.Contains(source.SourceKey))
- {
- return;
- }
-
- _pendingWallpaperSeedKeys.Add(source.SourceKey);
- }
-
- _ = Task.Run(() =>
- {
- WallpaperSeedExtractionResult? extractionResult = null;
-
- try
- {
- extractionResult = ExtractWallpaperSeedCandidates(source);
- }
- catch (Exception ex)
- {
- AppLogger.Warn(
- "Appearance.WallpaperSeed",
- $"Failed to build wallpaper seed candidates asynchronously. Source='{source.SourceKind}'; Path='{source.FilePath}'.",
- ex);
- }
- finally
- {
- lock (_paletteGate)
- {
- _pendingWallpaperSeedKeys.Remove(source.SourceKey);
- if (extractionResult is not null)
- {
- _wallpaperSeedCache[source.SourceKey] = extractionResult;
- }
- }
- }
-
- if (extractionResult is not null)
- {
- RaiseChanged(queueWallpaperPaletteBuild: false);
- }
- });
- }
-
- private WallpaperSeedExtractionResult ExtractWallpaperSeedCandidates(WallpaperSeedSourceDescriptor source)
- {
- IReadOnlyList seedCandidates = source.SourceKind switch
- {
- "app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
- "app_solid" when source.SolidColor is { } solidColor => new[] { solidColor },
- _ => []
- };
-
- return new WallpaperSeedExtractionResult(
- source.SourceKind,
- source.SourceKey,
- source.ResolvedWallpaperPath,
- seedCandidates);
- }
-
- private IReadOnlyList ExtractImageSeedCandidates(string? wallpaperPath)
- {
- if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath))
- {
- return [];
- }
-
- try
- {
- using var bitmap = new Bitmap(wallpaperPath);
- return _monetColorService.ExtractSeedCandidates(bitmap);
- }
- catch (Exception ex)
- {
- AppLogger.Warn(
- "Appearance.WallpaperSeed",
- $"Failed to extract wallpaper seed candidates from image '{wallpaperPath}'.",
- ex);
- return [];
- }
- }
-
- private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState)
- {
- if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
- !string.IsNullOrWhiteSpace(wallpaperState.Color) &&
- Color.TryParse(wallpaperState.Color, out var solidColor))
- {
- var solidText = solidColor.ToString();
- return new WallpaperSeedSourceDescriptor(
- "app_solid",
- $"app_solid|{solidText}",
- null,
- null,
- solidColor);
- }
-
- var wallpaperPath = string.IsNullOrWhiteSpace(wallpaperState.WallpaperPath)
- ? null
- : wallpaperState.WallpaperPath.Trim();
- var appWallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperPath);
- if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath))
- {
- if (appWallpaperMediaType == WallpaperMediaType.Image)
- {
- return new WallpaperSeedSourceDescriptor(
- "app_wallpaper",
- CreateWallpaperSourceKey("app_wallpaper", wallpaperPath),
- wallpaperPath,
- wallpaperPath,
- null);
- }
- }
-
- var systemWallpaper = _systemWallpaperService.GetWallpaperPath();
- if (!string.IsNullOrWhiteSpace(systemWallpaper) &&
- File.Exists(systemWallpaper) &&
- _settingsFacade.WallpaperMedia.DetectMediaType(systemWallpaper) == WallpaperMediaType.Image)
- {
- return new WallpaperSeedSourceDescriptor(
- "system_wallpaper",
- CreateWallpaperSourceKey("system_wallpaper", systemWallpaper),
- systemWallpaper,
- systemWallpaper,
- null);
- }
-
- return new WallpaperSeedSourceDescriptor(
- "fallback",
- "fallback",
- null,
- null,
- null);
- }
-
- private void RaiseChanged(bool queueWallpaperPaletteBuild)
- {
- var snapshot = BuildCurrentSnapshot(queueWallpaperPaletteBuild);
- if (Dispatcher.UIThread.CheckAccess())
- {
- Changed?.Invoke(this, snapshot);
- return;
- }
-
- Dispatcher.UIThread.Post(() => Changed?.Invoke(this, snapshot), DispatcherPriority.Background);
- }
-
- private static Color? ResolveSelectedWallpaperSeed(
- IReadOnlyList seedCandidates,
- string? selectedWallpaperSeed)
- {
- if (seedCandidates.Count == 0)
- {
- return null;
- }
-
- if (!string.IsNullOrWhiteSpace(selectedWallpaperSeed) &&
- Color.TryParse(selectedWallpaperSeed, out var parsedSeed))
- {
- foreach (var candidate in seedCandidates)
- {
- if (candidate == parsedSeed)
- {
- return candidate;
- }
- }
- }
-
- return seedCandidates[0];
- }
-
- private static string CreateWallpaperSourceKey(string sourceKind, string wallpaperPath)
- {
- long lastWriteTicks = 0;
- long length = 0;
-
- try
- {
- var fileInfo = new FileInfo(wallpaperPath);
- if (fileInfo.Exists)
- {
- lastWriteTicks = fileInfo.LastWriteTimeUtc.Ticks;
- length = fileInfo.Length;
- }
- }
- catch
- {
- // Keep the cache key resilient even if metadata lookup fails.
- }
-
- return string.Concat(
- sourceKind,
- "|",
- wallpaperPath,
- "|",
- lastWriteTicks.ToString(),
- "|",
- length.ToString());
+ Changed?.Invoke(this, snapshot);
}
}
internal static class HostAppearanceThemeProvider
{
private static readonly object Gate = new();
- private static AppearanceThemeService? _instance;
+ private static MaterialColorService? _materialColorService;
+ private static AppearanceThemeService? _appearanceThemeService;
public static IAppearanceThemeService GetOrCreate()
{
lock (Gate)
{
- return _instance ??= new AppearanceThemeService(
- HostSettingsFacadeProvider.GetOrCreate(),
- new SystemWallpaperService(),
- new WindowMaterialService(),
- new MaterialSurfaceService());
+ return _appearanceThemeService ??= new AppearanceThemeService(GetMaterialColorServiceCore());
}
}
+
+ internal static MaterialColorService GetMaterialColorService()
+ {
+ lock (Gate)
+ {
+ return GetMaterialColorServiceCore();
+ }
+ }
+
+ private static MaterialColorService GetMaterialColorServiceCore()
+ {
+ return _materialColorService ??= new MaterialColorService(
+ HostSettingsFacadeProvider.GetOrCreate(),
+ HostSystemWallpaperProvider.GetOrCreate(),
+ new WindowMaterialService(),
+ new MaterialSurfaceService());
+ }
+}
+
+internal static class HostMaterialColorProvider
+{
+ public static IMaterialColorService GetOrCreate()
+ {
+ return HostAppearanceThemeProvider.GetMaterialColorService();
+ }
}
diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsSnapshot.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsSnapshot.cs
new file mode 100644
index 0000000..ea4834f
--- /dev/null
+++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsSnapshot.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using System.Linq;
+using LanMountainDesktop.Services;
+
+namespace LanMountainDesktop.Services.ClockAirApp;
+
+public sealed class ClockAirAppSettingsSnapshot
+{
+ public string TimeFormatMode { get; set; } = ClockAirAppTimeFormatMode.System;
+
+ public bool ShowSeconds { get; set; } = true;
+
+ public string StartupTab { get; set; } = ClockAirAppTabIds.Last;
+
+ public string LastSelectedTab { get; set; } = ClockAirAppTabIds.WorldClock;
+
+ public bool ActivateOnTimerFinished { get; set; } = true;
+
+ public List WorldClockTimeZoneIds { get; set; } =
+ [
+ "China Standard Time",
+ "GMT Standard Time",
+ "AUS Eastern Standard Time",
+ "Eastern Standard Time"
+ ];
+
+ public ClockAirAppSettingsSnapshot Clone()
+ {
+ return new ClockAirAppSettingsSnapshot
+ {
+ TimeFormatMode = ClockAirAppTimeFormatMode.Normalize(TimeFormatMode),
+ ShowSeconds = ShowSeconds,
+ StartupTab = ClockAirAppTabIds.Normalize(StartupTab, ClockAirAppTabIds.Last),
+ LastSelectedTab = ClockAirAppTabIds.Normalize(LastSelectedTab),
+ ActivateOnTimerFinished = ActivateOnTimerFinished,
+ WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
+ ? new List(WorldClockTimeZoneIds.Where(static id => !string.IsNullOrWhiteSpace(id)).Select(static id => id.Trim()))
+ : []
+ };
+ }
+
+ public static ClockAirAppSettingsSnapshot Normalize(ClockAirAppSettingsSnapshot? snapshot)
+ {
+ var normalized = (snapshot ?? new ClockAirAppSettingsSnapshot()).Clone();
+ if (normalized.WorldClockTimeZoneIds.Count == 0)
+ {
+ normalized.WorldClockTimeZoneIds =
+ [
+ "China Standard Time",
+ "GMT Standard Time",
+ "AUS Eastern Standard Time",
+ "Eastern Standard Time"
+ ];
+ }
+
+ normalized.WorldClockTimeZoneIds = normalized.WorldClockTimeZoneIds
+ .Select(static id => WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(id).Id)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ return normalized;
+ }
+}
diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsStore.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsStore.cs
new file mode 100644
index 0000000..74822c4
--- /dev/null
+++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppSettingsStore.cs
@@ -0,0 +1,67 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using LanMountainDesktop.Services;
+
+namespace LanMountainDesktop.Services.ClockAirApp;
+
+public sealed class ClockAirAppSettingsStore
+{
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ private readonly string _settingsPath;
+
+ public ClockAirAppSettingsStore()
+ : this(Path.Combine(AppDataPathProvider.GetDataRoot(), "AirApps", "Clock", "settings.json"))
+ {
+ }
+
+ public ClockAirAppSettingsStore(string settingsPath)
+ {
+ _settingsPath = settingsPath;
+ }
+
+ public string SettingsPath => _settingsPath;
+
+ public ClockAirAppSettingsSnapshot Load()
+ {
+ try
+ {
+ if (!File.Exists(_settingsPath))
+ {
+ return ClockAirAppSettingsSnapshot.Normalize(null);
+ }
+
+ var json = File.ReadAllText(_settingsPath);
+ var snapshot = JsonSerializer.Deserialize(json, SerializerOptions);
+ return ClockAirAppSettingsSnapshot.Normalize(snapshot);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("ClockAirApp", $"Failed to load clock Air APP settings from '{_settingsPath}'.", ex);
+ return ClockAirAppSettingsSnapshot.Normalize(null);
+ }
+ }
+
+ public void Save(ClockAirAppSettingsSnapshot snapshot)
+ {
+ var normalized = ClockAirAppSettingsSnapshot.Normalize(snapshot);
+ try
+ {
+ var directory = Path.GetDirectoryName(_settingsPath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(_settingsPath, JsonSerializer.Serialize(normalized, SerializerOptions));
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("ClockAirApp", $"Failed to save clock Air APP settings to '{_settingsPath}'.", ex);
+ }
+ }
+}
diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppStopwatchState.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppStopwatchState.cs
new file mode 100644
index 0000000..ddea6a5
--- /dev/null
+++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppStopwatchState.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+
+namespace LanMountainDesktop.Services.ClockAirApp;
+
+public sealed class ClockAirAppStopwatchState
+{
+ private readonly List _laps = [];
+ private TimeSpan _elapsedBeforeRun = TimeSpan.Zero;
+ private DateTimeOffset? _startedAt;
+
+ public bool IsRunning => _startedAt.HasValue;
+
+ public IReadOnlyList Laps => _laps;
+
+ public TimeSpan GetElapsed(DateTimeOffset now)
+ {
+ return _startedAt.HasValue
+ ? _elapsedBeforeRun + (now - _startedAt.Value)
+ : _elapsedBeforeRun;
+ }
+
+ public void StartOrResume(DateTimeOffset now)
+ {
+ if (_startedAt.HasValue)
+ {
+ return;
+ }
+
+ _startedAt = now;
+ }
+
+ public void Pause(DateTimeOffset now)
+ {
+ if (!_startedAt.HasValue)
+ {
+ return;
+ }
+
+ _elapsedBeforeRun = GetElapsed(now);
+ _startedAt = null;
+ }
+
+ public TimeSpan AddLap(DateTimeOffset now)
+ {
+ var elapsed = GetElapsed(now);
+ _laps.Insert(0, elapsed);
+ if (_laps.Count > 50)
+ {
+ _laps.RemoveRange(50, _laps.Count - 50);
+ }
+
+ return elapsed;
+ }
+
+ public void Reset()
+ {
+ _elapsedBeforeRun = TimeSpan.Zero;
+ _startedAt = null;
+ _laps.Clear();
+ }
+}
diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTabIds.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTabIds.cs
new file mode 100644
index 0000000..2df6df4
--- /dev/null
+++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTabIds.cs
@@ -0,0 +1,23 @@
+namespace LanMountainDesktop.Services.ClockAirApp;
+
+public static class ClockAirAppTabIds
+{
+ public const string Last = "last";
+ public const string WorldClock = "world";
+ public const string Stopwatch = "stopwatch";
+ public const string Timer = "timer";
+ public const string Settings = "settings";
+
+ public static string Normalize(string? value, string fallback = WorldClock)
+ {
+ return value?.Trim().ToLowerInvariant() switch
+ {
+ Last => Last,
+ WorldClock => WorldClock,
+ Stopwatch => Stopwatch,
+ Timer => Timer,
+ Settings => Settings,
+ _ => fallback
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimeFormatMode.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimeFormatMode.cs
new file mode 100644
index 0000000..fb4ef11
--- /dev/null
+++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimeFormatMode.cs
@@ -0,0 +1,18 @@
+namespace LanMountainDesktop.Services.ClockAirApp;
+
+public static class ClockAirAppTimeFormatMode
+{
+ public const string System = "system";
+ public const string TwentyFourHour = "24h";
+ public const string TwelveHour = "12h";
+
+ public static string Normalize(string? value)
+ {
+ return value?.Trim().ToLowerInvariant() switch
+ {
+ TwentyFourHour => TwentyFourHour,
+ TwelveHour => TwelveHour,
+ _ => System
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimeFormatter.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimeFormatter.cs
new file mode 100644
index 0000000..9eb34c8
--- /dev/null
+++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimeFormatter.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace LanMountainDesktop.Services.ClockAirApp;
+
+public static class ClockAirAppTimeFormatter
+{
+ private static readonly IReadOnlyDictionary> CityNames =
+ new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["zh-CN"] = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["China Standard Time"] = "北京",
+ ["Asia/Shanghai"] = "北京",
+ ["GMT Standard Time"] = "伦敦",
+ ["Europe/London"] = "伦敦",
+ ["AUS Eastern Standard Time"] = "悉尼",
+ ["Australia/Sydney"] = "悉尼",
+ ["Eastern Standard Time"] = "纽约",
+ ["America/New_York"] = "纽约",
+ ["Tokyo Standard Time"] = "东京",
+ ["Asia/Tokyo"] = "东京",
+ ["UTC"] = "UTC",
+ ["Etc/UTC"] = "UTC"
+ },
+ ["en-US"] = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["China Standard Time"] = "Beijing",
+ ["Asia/Shanghai"] = "Beijing",
+ ["GMT Standard Time"] = "London",
+ ["Europe/London"] = "London",
+ ["AUS Eastern Standard Time"] = "Sydney",
+ ["Australia/Sydney"] = "Sydney",
+ ["Eastern Standard Time"] = "New York",
+ ["America/New_York"] = "New York",
+ ["Tokyo Standard Time"] = "Tokyo",
+ ["Asia/Tokyo"] = "Tokyo",
+ ["UTC"] = "UTC",
+ ["Etc/UTC"] = "UTC"
+ },
+ ["ja-JP"] = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["China Standard Time"] = "北京",
+ ["Asia/Shanghai"] = "北京",
+ ["GMT Standard Time"] = "ロンドン",
+ ["Europe/London"] = "ロンドン",
+ ["AUS Eastern Standard Time"] = "シドニー",
+ ["Australia/Sydney"] = "シドニー",
+ ["Eastern Standard Time"] = "ニューヨーク",
+ ["America/New_York"] = "ニューヨーク",
+ ["Tokyo Standard Time"] = "東京",
+ ["Asia/Tokyo"] = "東京",
+ ["UTC"] = "UTC",
+ ["Etc/UTC"] = "UTC"
+ },
+ ["ko-KR"] = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["China Standard Time"] = "베이징",
+ ["Asia/Shanghai"] = "베이징",
+ ["GMT Standard Time"] = "런던",
+ ["Europe/London"] = "런던",
+ ["AUS Eastern Standard Time"] = "시드니",
+ ["Australia/Sydney"] = "시드니",
+ ["Eastern Standard Time"] = "뉴욕",
+ ["America/New_York"] = "뉴욕",
+ ["Tokyo Standard Time"] = "도쿄",
+ ["Asia/Tokyo"] = "도쿄",
+ ["UTC"] = "UTC",
+ ["Etc/UTC"] = "UTC"
+ }
+ };
+
+ public static string FormatTime(DateTime time, ClockAirAppSettingsSnapshot settings, CultureInfo culture)
+ {
+ var use24Hour = UseTwentyFourHourClock(settings.TimeFormatMode, culture);
+ var showSeconds = settings.ShowSeconds;
+ var format = use24Hour
+ ? showSeconds ? "HH:mm:ss" : "HH:mm"
+ : showSeconds ? "h:mm:ss tt" : "h:mm tt";
+ return time.ToString(format, culture);
+ }
+
+ public static string FormatDuration(TimeSpan duration, bool includeMilliseconds = false)
+ {
+ if (duration < TimeSpan.Zero)
+ {
+ duration = TimeSpan.Zero;
+ }
+
+ var totalHours = (int)duration.TotalHours;
+ return includeMilliseconds
+ ? string.Create(CultureInfo.InvariantCulture, $"{totalHours:D2}:{duration.Minutes:D2}:{duration.Seconds:D2}.{duration.Milliseconds / 10:D2}")
+ : string.Create(CultureInfo.InvariantCulture, $"{totalHours:D2}:{duration.Minutes:D2}:{duration.Seconds:D2}");
+ }
+
+ public static string FormatUtcOffset(TimeSpan offset)
+ {
+ var sign = offset >= TimeSpan.Zero ? "+" : "-";
+ var totalMinutes = Math.Abs((int)Math.Round(offset.TotalMinutes));
+ var hours = totalMinutes / 60;
+ var minutes = totalMinutes % 60;
+ return $"UTC{sign}{hours:D2}:{minutes:D2}";
+ }
+
+ public static string ResolveCityName(TimeZoneInfo timeZone, string languageCode)
+ {
+ var normalizedLanguage = NormalizeLanguage(languageCode);
+ if (CityNames.TryGetValue(normalizedLanguage, out var cityNames) &&
+ cityNames.TryGetValue(timeZone.Id, out var cityName))
+ {
+ return cityName;
+ }
+
+ var normalized = timeZone.Id;
+ var slashIndex = normalized.LastIndexOf('/');
+ if (slashIndex >= 0 && slashIndex < normalized.Length - 1)
+ {
+ normalized = normalized[(slashIndex + 1)..];
+ }
+
+ normalized = normalized.Replace('_', ' ').Trim();
+ normalized = normalized
+ .Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Trim();
+
+ return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized;
+ }
+
+ public static bool UseTwentyFourHourClock(string? timeFormatMode, CultureInfo culture)
+ {
+ return ClockAirAppTimeFormatMode.Normalize(timeFormatMode) switch
+ {
+ ClockAirAppTimeFormatMode.TwentyFourHour => true,
+ ClockAirAppTimeFormatMode.TwelveHour => false,
+ _ => !culture.DateTimeFormat.ShortTimePattern.Contains('h')
+ };
+ }
+
+ private static string NormalizeLanguage(string? languageCode)
+ {
+ return languageCode?.Trim().ToLowerInvariant() switch
+ {
+ "en" or "en-us" => "en-US",
+ "ja" or "ja-jp" => "ja-JP",
+ "ko" or "ko-kr" => "ko-KR",
+ _ => "zh-CN"
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimerState.cs b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimerState.cs
new file mode 100644
index 0000000..2b91259
--- /dev/null
+++ b/LanMountainDesktop/Services/ClockAirApp/ClockAirAppTimerState.cs
@@ -0,0 +1,90 @@
+using System;
+
+namespace LanMountainDesktop.Services.ClockAirApp;
+
+public sealed class ClockAirAppTimerState
+{
+ private TimeSpan _duration = TimeSpan.FromMinutes(5);
+ private TimeSpan _remainingBeforeRun = TimeSpan.FromMinutes(5);
+ private DateTimeOffset? _startedAt;
+
+ public TimeSpan Duration => _duration;
+
+ public bool IsRunning => _startedAt.HasValue;
+
+ public bool IsCompleted { get; private set; }
+
+ public TimeSpan GetRemaining(DateTimeOffset now)
+ {
+ if (!_startedAt.HasValue)
+ {
+ return _remainingBeforeRun < TimeSpan.Zero ? TimeSpan.Zero : _remainingBeforeRun;
+ }
+
+ var remaining = _remainingBeforeRun - (now - _startedAt.Value);
+ return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
+ }
+
+ public void SetDuration(TimeSpan duration)
+ {
+ if (duration <= TimeSpan.Zero)
+ {
+ duration = TimeSpan.FromMinutes(1);
+ }
+
+ _duration = duration;
+ Reset();
+ }
+
+ public void StartOrResume(DateTimeOffset now)
+ {
+ if (_startedAt.HasValue)
+ {
+ return;
+ }
+
+ if (_remainingBeforeRun <= TimeSpan.Zero || IsCompleted)
+ {
+ _remainingBeforeRun = _duration;
+ IsCompleted = false;
+ }
+
+ _startedAt = now;
+ }
+
+ public void Pause(DateTimeOffset now)
+ {
+ if (!_startedAt.HasValue)
+ {
+ return;
+ }
+
+ _remainingBeforeRun = GetRemaining(now);
+ _startedAt = null;
+ }
+
+ public void Reset()
+ {
+ _remainingBeforeRun = _duration;
+ _startedAt = null;
+ IsCompleted = false;
+ }
+
+ public bool Update(DateTimeOffset now)
+ {
+ if (!_startedAt.HasValue || GetRemaining(now) > TimeSpan.Zero)
+ {
+ return false;
+ }
+
+ _remainingBeforeRun = TimeSpan.Zero;
+ _startedAt = null;
+ if (IsCompleted)
+ {
+ return false;
+ }
+
+ IsCompleted = true;
+ return true;
+ }
+}
diff --git a/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs
index 47a9006..c7bba52 100644
--- a/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs
+++ b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs
@@ -1,9 +1,5 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
using Avalonia.Media;
using LanMountainDesktop.Models;
-using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Services;
@@ -28,89 +24,46 @@ internal sealed record ComponentEditorThemePalette(
internal static class ComponentEditorMaterialThemeAdapter
{
- private static readonly Color DefaultPrimary = Color.Parse("#FF6750A4");
- private static readonly Color DarkBackgroundBase = Color.Parse("#FF0B0F14");
- private static readonly Color DarkSurfaceBase = Color.Parse("#FF10161D");
- private static readonly Color DarkSurfaceContainerBase = Color.Parse("#FF151C24");
- private static readonly Color DarkSurfaceContainerHighBase = Color.Parse("#FF1A232D");
- private static readonly Color LightBackgroundBase = Color.Parse("#FFFCFCFF");
- private static readonly Color LightSurfaceBase = Color.Parse("#FFFFFFFF");
- private static readonly Color LightSurfaceContainerBase = Color.Parse("#FFF6F8FD");
- private static readonly Color LightSurfaceContainerHighBase = Color.Parse("#FFF0F4FA");
- private static readonly Color LightOnSurfaceBase = Color.Parse("#FF101316");
- private static readonly Color DarkOnSurfaceBase = Color.Parse("#FFF6F8FC");
+ private static readonly Color FallbackPrimary = Color.Parse("#FF6750A4");
- public static ComponentEditorThemePalette Build(
- ThemeAppearanceSettingsState themeState,
- WallpaperSettingsState wallpaperState,
- MonetPalette monetPalette,
- WallpaperMediaType wallpaperMediaType)
+ public static ComponentEditorThemePalette Build(MaterialColorSnapshot snapshot)
{
- ArgumentNullException.ThrowIfNull(monetPalette);
+ ArgumentNullException.ThrowIfNull(snapshot);
- var isNightMode = themeState.IsNightMode;
- var fallbackThemeColor = TryParseColor(themeState.ThemeColor);
- var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetPalette.Primary.A > 0;
+ var palette = snapshot.Palette;
+ var isNightMode = snapshot.IsNightMode;
+ var primary = FirstUsable(palette.Primary, palette.Accent, snapshot.AccentColor, FallbackPrimary);
+ var secondary = FirstUsable(
+ palette.Secondary,
+ snapshot.MonetPalette.Secondary,
+ ColorMath.Blend(primary, isNightMode ? Colors.White : Color.Parse("#FF1F1B24"), isNightMode ? 0.18 : 0.16));
+ var tertiary = FirstUsable(
+ snapshot.MonetPalette.Tertiary,
+ ColorMath.Blend(ColorMath.Blend(primary, secondary, 0.5), isNightMode ? Colors.White : Color.Parse("#FF2A2230"), isNightMode ? 0.12 : 0.14));
- var primary = useWallpaperPalette
- ? monetPalette.Primary
- : fallbackThemeColor ?? monetPalette.Primary;
- if (primary == default)
- {
- primary = DefaultPrimary;
- }
-
- var secondary = ResolveSecondaryColor(primary, monetPalette, isNightMode);
- var tertiary = ResolveTertiaryColor(primary, secondary, monetPalette, isNightMode);
-
- var backgroundBase = isNightMode ? DarkBackgroundBase : LightBackgroundBase;
- var surfaceBase = isNightMode ? DarkSurfaceBase : LightSurfaceBase;
- var surfaceContainerBase = isNightMode ? DarkSurfaceContainerBase : LightSurfaceContainerBase;
- var surfaceContainerHighBase = isNightMode ? DarkSurfaceContainerHighBase : LightSurfaceContainerHighBase;
-
- var background = ColorMath.Blend(backgroundBase, primary, isNightMode ? 0.10 : 0.025);
- var surface = ColorMath.Blend(surfaceBase, primary, isNightMode ? 0.12 : 0.035);
- var surfaceContainer = ColorMath.Blend(surfaceContainerBase, primary, isNightMode ? 0.18 : 0.065);
- var surfaceContainerHigh = ColorMath.Blend(surfaceContainerHighBase, primary, isNightMode ? 0.24 : 0.09);
+ var windowBackground = GetSurfaceColor(snapshot, MaterialSurfaceRole.WindowBackground, palette.SurfaceBase);
+ var surface = FirstUsable(palette.SurfaceRaised, GetSurfaceColor(snapshot, MaterialSurfaceRole.SettingsWindowBackground, palette.SurfaceBase));
+ var surfaceContainer = FirstUsable(palette.SurfaceOverlay, GetSurfaceColor(snapshot, MaterialSurfaceRole.DesktopComponentHost, surface));
+ var surfaceContainerHigh = GetSurfaceColor(snapshot, MaterialSurfaceRole.OverlayPanel, surfaceContainer);
var topAppBar = ColorMath.Blend(surfaceContainerHigh, primary, isNightMode ? 0.10 : 0.06);
- var onSurfaceBase = isNightMode ? DarkOnSurfaceBase : LightOnSurfaceBase;
- var onSurface = ColorMath.EnsureContrast(onSurfaceBase, background, 7.0);
- var onSurfaceVariantBase = ColorMath.Blend(
- onSurface,
- surfaceContainer,
- isNightMode ? 0.30 : 0.42);
- var onSurfaceVariant = ColorMath.EnsureContrast(onSurfaceVariantBase, surfaceContainer, 4.5);
- var outlineBase = ColorMath.Blend(onSurface, surfaceContainer, isNightMode ? 0.74 : 0.82);
- var outline = Color.FromArgb(
- isNightMode ? (byte)0x66 : (byte)0x42,
- outlineBase.R,
- outlineBase.G,
- outlineBase.B);
- var divider = Color.FromArgb(
- isNightMode ? (byte)0x52 : (byte)0x26,
- outlineBase.R,
- outlineBase.G,
- outlineBase.B);
- var headerIconBackground = Color.FromArgb(
- isNightMode ? (byte)0x36 : (byte)0x1F,
- primary.R,
- primary.G,
- primary.B);
- var titleBarButtonHover = Color.FromArgb(
- isNightMode ? (byte)0x24 : (byte)0x12,
- onSurface.R,
- onSurface.G,
- onSurface.B);
- var onPrimaryBase = isNightMode ? Color.Parse("#FF111318") : Color.Parse("#FFFFFFFF");
- var onPrimary = ColorMath.EnsureContrast(onPrimaryBase, primary, 4.5);
+ var textPrimary = FirstUsable(palette.TextPrimary, isNightMode ? Colors.White : Color.Parse("#FF101316"));
+ var textSecondary = FirstUsable(palette.TextSecondary, palette.TextMuted, ColorMath.Blend(textPrimary, surfaceContainer, isNightMode ? 0.30 : 0.42));
+ var outline = FirstUsable(
+ GetSurfaceBorder(snapshot, MaterialSurfaceRole.DesktopComponentHost),
+ palette.ToggleBorder,
+ ColorMath.WithAlpha(ColorMath.Blend(textPrimary, surfaceContainer, isNightMode ? 0.74 : 0.82), isNightMode ? (byte)0x66 : (byte)0x42));
+ var divider = ColorMath.WithAlpha(outline, isNightMode ? (byte)0x52 : (byte)0x26);
+ var headerIconBackground = Color.FromArgb(isNightMode ? (byte)0x36 : (byte)0x1F, primary.R, primary.G, primary.B);
+ var titleBarButtonHover = Color.FromArgb(isNightMode ? (byte)0x24 : (byte)0x12, textPrimary.R, textPrimary.G, textPrimary.B);
+ var onPrimary = FirstUsable(palette.OnAccent, ColorMath.EnsureContrast(Colors.White, primary, 4.5));
return new ComponentEditorThemePalette(
isNightMode,
primary,
secondary,
tertiary,
- background,
+ windowBackground,
surface,
surfaceContainer,
surfaceContainerHigh,
@@ -119,43 +72,35 @@ internal static class ComponentEditorMaterialThemeAdapter
titleBarButtonHover,
outline,
divider,
- onSurface,
- onSurfaceVariant,
+ textPrimary,
+ textSecondary,
onPrimary);
}
- private static Color ResolveSecondaryColor(Color primary, MonetPalette monetPalette, bool isNightMode)
+ private static Color GetSurfaceColor(MaterialColorSnapshot snapshot, MaterialSurfaceRole role, Color fallback)
{
- if (monetPalette.Secondary != default)
- {
- return monetPalette.Secondary;
- }
-
- return ColorMath.Blend(
- primary,
- isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF1F1B24"),
- isNightMode ? 0.18 : 0.16);
+ return snapshot.Surfaces.TryGetValue(role, out var surface) && surface.BackgroundColor.A > 0
+ ? surface.BackgroundColor
+ : fallback;
}
- private static Color ResolveTertiaryColor(
- Color primary,
- Color secondary,
- MonetPalette monetPalette,
- bool isNightMode)
+ private static Color GetSurfaceBorder(MaterialColorSnapshot snapshot, MaterialSurfaceRole role)
{
- if (monetPalette.Tertiary != default)
- {
- return monetPalette.Tertiary;
- }
-
- var blendTarget = isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF2A2230");
- return ColorMath.Blend(ColorMath.Blend(primary, secondary, 0.5), blendTarget, isNightMode ? 0.12 : 0.14);
+ return snapshot.Surfaces.TryGetValue(role, out var surface)
+ ? surface.BorderColor
+ : default;
}
- private static Color? TryParseColor(string? value)
+ private static Color FirstUsable(params Color[] colors)
{
- return !string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out var parsed)
- ? parsed
- : null;
+ foreach (var color in colors)
+ {
+ if (color.A > 0)
+ {
+ return color;
+ }
+ }
+
+ return default;
}
}
diff --git a/LanMountainDesktop/Services/ComponentEditorWindowService.cs b/LanMountainDesktop/Services/ComponentEditorWindowService.cs
index 2b51d45..518f62d 100644
--- a/LanMountainDesktop/Services/ComponentEditorWindowService.cs
+++ b/LanMountainDesktop/Services/ComponentEditorWindowService.cs
@@ -1,5 +1,4 @@
using System;
-using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
@@ -31,16 +30,15 @@ public interface IComponentEditorWindowService
internal sealed class ComponentEditorWindowService : IComponentEditorWindowService
{
private readonly ISettingsFacadeService _settingsFacade;
- private readonly IAppearanceThemeService _appearanceThemeService;
+ private readonly IMaterialColorService _materialColorService;
private ComponentEditorWindow? _window;
private string? _currentPlacementId;
public ComponentEditorWindowService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
- _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
- _settingsFacade.Settings.Changed += OnSettingsChanged;
- _appearanceThemeService.Changed += OnAppearanceThemeChanged;
+ _materialColorService = HostMaterialColorProvider.GetOrCreate();
+ _materialColorService.MaterialColorChanged += OnMaterialColorChanged;
}
public bool IsOpen => _window is { IsVisible: true };
@@ -100,60 +98,29 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi
return window;
}
- private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
- {
- if (_window is null || e.Scope != SettingsScope.App)
- {
- return;
- }
-
- var changedKeys = e.ChangedKeys?.ToArray() ?? [];
- var liveAppearance = _appearanceThemeService.GetCurrent();
- if (changedKeys.Length > 0 &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
- !(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
- changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
- !(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
- (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase))
- {
- return;
- }
-
- ApplyTheme(_window);
- }
-
private void ApplyTheme(ComponentEditorWindow window)
{
- var appearanceSnapshot = _appearanceThemeService.GetCurrent();
- var themeState = _settingsFacade.Theme.Get();
- var wallpaperState = _settingsFacade.Wallpaper.Get();
- var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(
- appearanceSnapshot.ResolvedWallpaperPath ?? wallpaperState.WallpaperPath);
- var palette = ComponentEditorMaterialThemeAdapter.Build(
- themeState,
- wallpaperState,
- appearanceSnapshot.MonetPalette,
- wallpaperMediaType);
+ var snapshot = _materialColorService.GetMaterialColorSnapshot();
+ var palette = ComponentEditorMaterialThemeAdapter.Build(snapshot);
window.ApplyTheme(palette);
- window.ApplyChromeMode(themeState.UseSystemChrome);
- _appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground);
+ window.ApplyChromeMode(snapshot.UseSystemChrome);
+ _materialColorService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground);
}
- private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
+ private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot)
{
_ = sender;
- _ = e;
if (_window is null)
{
return;
}
- ApplyTheme(_window);
+ var palette = ComponentEditorMaterialThemeAdapter.Build(snapshot);
+ _window.ApplyTheme(palette);
+ _window.ApplyChromeMode(snapshot.UseSystemChrome);
+ _materialColorService.ApplyWindowMaterial(_window, MaterialSurfaceRole.WindowBackground);
}
private sealed class HostContext : IComponentEditorHostContext
diff --git a/LanMountainDesktop/Services/DataStorageService.cs b/LanMountainDesktop/Services/DataStorageService.cs
new file mode 100644
index 0000000..fe8c3cb
--- /dev/null
+++ b/LanMountainDesktop/Services/DataStorageService.cs
@@ -0,0 +1,357 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LanMountainDesktop.Services;
+
+public sealed record StorageCategoryInfo(
+ string Id,
+ string Name,
+ string Description,
+ string DirectoryPath,
+ bool IsCleanable,
+ string ColorHex);
+
+public sealed record StorageScanResult(
+ StorageCategoryInfo Category,
+ long SizeBytes,
+ double PercentageOfTotal);
+
+public sealed class DataStorageService
+{
+ private static readonly string[] SettingsRootFiles =
+ [
+ "settings.json",
+ "plugin-settings.json",
+ "launcher-settings.json",
+ "app.db",
+ "app.db-shm",
+ "app.db-wal"
+ ];
+
+ private static readonly string[] CategoryRelativeDirectories =
+ [
+ "Whiteboards",
+ "Extensions",
+ "PluginMarket",
+ "Wallpapers"
+ ];
+
+ private static readonly IReadOnlyList Categories = new List
+ {
+ new("logs", "日志文件", "应用运行日志", "", true, "#9E9E9E"),
+ new("whiteboards", "白板笔记", "桌面白板笔记数据", "Whiteboards", true, "#FF9800"),
+ new("plugins", "插件数据", "已安装插件文件", "Extensions/Plugins", true, "#2196F3"),
+ new("market", "插件市场缓存", "插件市场元数据缓存", "PluginMarket", true, "#9C27B0"),
+ new("wallpapers", "壁纸文件", "下载的壁纸资源", "Wallpapers", true, "#E91E63"),
+ new("settings", "设置文件", "应用配置数据", "", false, "#4CAF50")
+ };
+
+ public IReadOnlyList GetCategories() => Categories;
+
+ public async Task> ScanAsync(CancellationToken cancellationToken = default)
+ {
+ var results = new List();
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ var logDirectory = AppLogger.LogDirectory;
+
+ long totalSize = 0;
+ var categorySizes = new Dictionary();
+
+ foreach (var category in Categories)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string path = category.Id switch
+ {
+ "logs" => logDirectory,
+ "settings" => dataRoot,
+ _ => Path.Combine(dataRoot, category.DirectoryPath)
+ };
+
+ long size = 0;
+ if (category.Id == "settings")
+ {
+ size = await GetSettingsSizeAsync(dataRoot, cancellationToken);
+ }
+ else if (Directory.Exists(path))
+ {
+ size = await GetDirectorySizeAsync(path, cancellationToken);
+ }
+
+ categorySizes[category.Id] = size;
+ totalSize += size;
+ }
+
+ foreach (var category in Categories)
+ {
+ var size = categorySizes.GetValueOrDefault(category.Id, 0);
+ var percentage = totalSize > 0 ? (double)size / totalSize * 100 : 0;
+ results.Add(new StorageScanResult(category, size, percentage));
+ }
+
+ return results;
+ }
+
+ public async Task GetTotalDiskSpaceAsync(CancellationToken cancellationToken = default)
+ {
+ return await Task.Run(() =>
+ {
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ var pathRoot = Path.GetPathRoot(dataRoot);
+ if (string.IsNullOrWhiteSpace(pathRoot))
+ {
+ return 0;
+ }
+
+ var driveInfo = new DriveInfo(pathRoot);
+ return driveInfo.TotalSize;
+ }, cancellationToken);
+ }
+
+ public async Task GetAvailableDiskSpaceAsync(CancellationToken cancellationToken = default)
+ {
+ return await Task.Run(() =>
+ {
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ var pathRoot = Path.GetPathRoot(dataRoot);
+ if (string.IsNullOrWhiteSpace(pathRoot))
+ {
+ return 0;
+ }
+
+ var driveInfo = new DriveInfo(pathRoot);
+ return driveInfo.AvailableFreeSpace;
+ }, cancellationToken);
+ }
+
+ public async Task CleanCategoryAsync(string categoryId, CancellationToken cancellationToken = default)
+ {
+ var category = Categories.FirstOrDefault(c =>
+ string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase));
+
+ if (category is null || !category.IsCleanable)
+ {
+ return false;
+ }
+
+ var dataRoot = AppDataPathProvider.GetDataRoot();
+ string path = categoryId switch
+ {
+ "logs" => AppLogger.LogDirectory,
+ _ => Path.Combine(dataRoot, category.DirectoryPath)
+ };
+
+ if (!Directory.Exists(path))
+ {
+ return false;
+ }
+
+ return await Task.Run(() =>
+ {
+ try
+ {
+ if (categoryId == "logs")
+ {
+ foreach (var file in Directory.GetFiles(path, "*.log"))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ TryDeleteFile(file);
+ }
+ }
+ else
+ {
+ foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ TryDeleteFile(file);
+ }
+
+ foreach (var dir in Directory.GetDirectories(path, "*", SearchOption.AllDirectories)
+ .OrderByDescending(d => d.Length))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ TryDeleteDirectory(dir);
+ }
+ }
+
+ AppLogger.Info("DataStorage", $"Cleaned category '{categoryId}' at '{path}'.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("DataStorage", $"Failed to clean category '{categoryId}'.", ex);
+ return false;
+ }
+ }, cancellationToken);
+ }
+
+ private static async Task GetDirectorySizeAsync(string path, CancellationToken cancellationToken)
+ {
+ return await Task.Run(() => GetDirectorySizeCore(path, cancellationToken), cancellationToken);
+ }
+
+ private static async Task GetSettingsSizeAsync(string dataRoot, CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ long size = 0;
+ var countedFiles = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var file in SettingsRootFiles)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var path = Path.Combine(dataRoot, file);
+ if (File.Exists(path))
+ {
+ try
+ {
+ var fullPath = Path.GetFullPath(path);
+ size += new FileInfo(fullPath).Length;
+ countedFiles.Add(fullPath);
+ }
+ catch
+ {
+ // Ignore
+ }
+ }
+ }
+
+ // Include root-level auxiliary files (exclude known category directories and logs).
+ try
+ {
+ var excludedDirs = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var relativeDir in CategoryRelativeDirectories)
+ {
+ excludedDirs.Add(Path.GetFullPath(Path.Combine(dataRoot, relativeDir)));
+ }
+
+ var logDir = AppLogger.LogDirectory;
+ if (!string.IsNullOrWhiteSpace(logDir))
+ {
+ excludedDirs.Add(Path.GetFullPath(logDir));
+ }
+
+ foreach (var file in Directory.EnumerateFiles(dataRoot, "*", SearchOption.TopDirectoryOnly))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var info = new FileInfo(file);
+ if (!info.Exists)
+ {
+ continue;
+ }
+
+ var fullPath = Path.GetFullPath(file);
+ if (countedFiles.Contains(fullPath))
+ {
+ continue;
+ }
+
+ size += info.Length;
+ countedFiles.Add(fullPath);
+ }
+ catch
+ {
+ // Ignore file probe failures
+ }
+ }
+
+ foreach (var directory in Directory.EnumerateDirectories(dataRoot, "*", SearchOption.TopDirectoryOnly))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var fullPath = Path.GetFullPath(directory);
+ if (excludedDirs.Contains(fullPath))
+ {
+ continue;
+ }
+
+ size += GetDirectorySizeCore(fullPath, cancellationToken);
+ }
+ }
+ catch
+ {
+ // Ignore directory enumeration errors
+ }
+
+ return size;
+ }, cancellationToken);
+ }
+
+ private static long GetDirectorySizeCore(string path, CancellationToken cancellationToken)
+ {
+ long size = 0;
+ try
+ {
+ foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var info = new FileInfo(file);
+ if (info.Exists)
+ {
+ size += info.Length;
+ }
+ }
+ catch
+ {
+ // Ignore files we can't access
+ }
+ }
+ }
+ catch
+ {
+ // Ignore directories we can't access
+ }
+
+ return size;
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ File.SetAttributes(path, FileAttributes.Normal);
+ File.Delete(path);
+ }
+ catch
+ {
+ // Ignore deletion failures
+ }
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ Directory.Delete(path, false);
+ }
+ catch
+ {
+ // Ignore deletion failures
+ }
+ }
+
+ public static string FormatBytes(long bytes)
+ {
+ const long KB = 1024;
+ const long MB = KB * 1024;
+ const long GB = MB * 1024;
+ const long TB = GB * 1024;
+
+ return bytes switch
+ {
+ >= TB => $"{bytes / (double)TB:F2} TB",
+ >= GB => $"{bytes / (double)GB:F2} GB",
+ >= MB => $"{bytes / (double)MB:F2} MB",
+ >= KB => $"{bytes / (double)KB:F2} KB",
+ _ => $"{bytes} B"
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
index 7c76f7a..ad15fb6 100644
--- a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
+++ b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
@@ -35,12 +35,14 @@ public static class DesktopComponentRegistryFactory
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
ComponentRegistry componentRegistry,
PluginRuntimeService? pluginRuntimeService,
- ISettingsFacadeService settingsFacade)
+ ISettingsFacadeService settingsFacade,
+ IMaterialColorService? materialColorService = null)
{
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
var registeredIds = new HashSet(
registrations.Select(registration => registration.ComponentId),
StringComparer.OrdinalIgnoreCase);
+ var resolvedMaterialColorService = materialColorService ?? HostMaterialColorProvider.GetOrCreate();
if (pluginRuntimeService is not null)
{
@@ -62,7 +64,7 @@ public static class DesktopComponentRegistryFactory
registrations.Add(new DesktopComponentRuntimeRegistration(
registration.ComponentId,
registration.DisplayNameLocalizationKey,
- factoryContext => CreatePluginControl(contribution, factoryContext),
+ factoryContext => CreatePluginControl(contribution, factoryContext, resolvedMaterialColorService),
chromeContext =>
{
var appearanceContext = CreatePluginAppearanceContext(chromeContext);
@@ -118,7 +120,8 @@ public static class DesktopComponentRegistryFactory
private static Control CreatePluginControl(
PluginDesktopComponentContribution contribution,
- DesktopComponentControlFactoryContext context)
+ DesktopComponentControlFactoryContext context,
+ IMaterialColorService materialColorService)
{
try
{
@@ -127,10 +130,9 @@ public static class DesktopComponentRegistryFactory
var pluginSettings = new PluginScopedSettingsService(
contribution.Plugin.Manifest.Id,
settingsService);
- var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
- var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot(
- CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens),
- ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light"));
+ var pluginAppearance = new PluginAppearanceContext(
+ PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(
+ materialColorService.GetMaterialColorSnapshot()));
var pluginContext = new PluginDesktopComponentContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs
index 789d511..8177e32 100644
--- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs
+++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs
@@ -20,6 +20,7 @@ public interface IFusedDesktopManagerService
void EnterEditMode();
void ExitEditMode();
void ReloadWidgets();
+ void Shutdown();
}
///
@@ -124,6 +125,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
{
existingWindow.Show();
}
+
+ existingWindow.RefreshDesktopLayer();
}
else
{
@@ -136,6 +139,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
_widgetWindows[placement.PlacementId] = window;
window.Show();
window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
+ window.RefreshDesktopLayer();
}
}
catch (Exception ex)
@@ -155,6 +159,18 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
}
}
+ public void Shutdown()
+ {
+ _isEditMode = false;
+ foreach (var window in _widgetWindows.Values)
+ {
+ window.Close();
+ }
+
+ _widgetWindows.Clear();
+ AppLogger.Info("FusedDesktop", "Fused desktop manager shut down.");
+ }
+
private DesktopWidgetWindow? CreateWidgetWindow(FusedDesktopComponentPlacementSnapshot placement)
{
EnsureRegistries();
diff --git a/LanMountainDesktop/Services/GlassEffectService.cs b/LanMountainDesktop/Services/GlassEffectService.cs
index 2f071b7..7ee9fb5 100644
--- a/LanMountainDesktop/Services/GlassEffectService.cs
+++ b/LanMountainDesktop/Services/GlassEffectService.cs
@@ -7,9 +7,10 @@ namespace LanMountainDesktop.Services;
public static class GlassEffectService
{
+ private static readonly IMaterialSurfaceService MaterialSurfaceService = new MaterialSurfaceService();
+
public static void ApplyGlassResources(IResourceDictionary resources, ThemeColorContext context)
{
- var materialSurfaceService = new MaterialSurfaceService();
var monetPalette = context.MonetPalette;
var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
var primary = context.UseNeutralSurfaces
@@ -48,13 +49,13 @@ public static class GlassEffectService
ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.24 : 0.16),
context.IsNightMode ? (byte)0xF8 : (byte)0xFF));
- var windowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.WindowBackground);
- var settingsWindowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.SettingsWindowBackground);
- var dockSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DockBackground);
- var statusBarSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarBackground);
- var desktopComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DesktopComponentHost);
- var statusBarComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarComponentHost);
- var overlaySurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.OverlayPanel);
+ var windowSurface = MaterialSurfaceService.GetSurface(context, MaterialSurfaceRole.WindowBackground);
+ var settingsWindowSurface = MaterialSurfaceService.GetSurface(context, MaterialSurfaceRole.SettingsWindowBackground);
+ var dockSurface = MaterialSurfaceService.GetSurface(context, MaterialSurfaceRole.DockBackground);
+ var statusBarSurface = MaterialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarBackground);
+ var desktopComponentSurface = MaterialSurfaceService.GetSurface(context, MaterialSurfaceRole.DesktopComponentHost);
+ var statusBarComponentSurface = MaterialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarComponentHost);
+ var overlaySurface = MaterialSurfaceService.GetSurface(context, MaterialSurfaceRole.OverlayPanel);
var strongSurfaceColor = ColorMath.Blend(
desktopComponentSurface.BackgroundColor,
overlaySurface.BackgroundColor,
@@ -69,6 +70,15 @@ public static class GlassEffectService
resources["AdaptiveWindowBackgroundBrush"] = new SolidColorBrush(windowSurface.BackgroundColor);
resources["AdaptiveWindowBorderBrush"] = new SolidColorBrush(windowSurface.BorderColor);
resources["AdaptiveSettingsWindowBackgroundBrush"] = new SolidColorBrush(settingsWindowSurface.BackgroundColor);
+ // 可选:叠在内容区上的可读性 tint(半透明);不改变 AdaptiveSettingsWindowBackgroundBrush 的语义权重,供 P1 绑定内容层。
+ var settingsTintBase = settingsWindowSurface.BackgroundColor;
+ var settingsTintAlpha = ResolveSettingsWindowTintAlpha(context);
+ resources["AdaptiveSettingsWindowTintBrush"] = new SolidColorBrush(
+ Color.FromArgb(
+ settingsTintAlpha,
+ settingsTintBase.R,
+ settingsTintBase.G,
+ settingsTintBase.B));
resources["AdaptiveSettingsWindowBorderBrush"] = new SolidColorBrush(settingsWindowSurface.BorderColor);
resources["AdaptiveDockBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor);
resources["AdaptiveDockBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor);
@@ -100,4 +110,16 @@ public static class GlassEffectService
resources["AdaptiveDesktopComponentHostOpacity"] = desktopComponentSurface.Opacity;
resources["AdaptiveStatusBarComponentHostOpacity"] = statusBarComponentSurface.Opacity;
}
+
+ /// 可选内容叠层 alpha,与设置窗表面色相一致;None 为 0 避免重复染色。
+ private static byte ResolveSettingsWindowTintAlpha(ThemeColorContext context)
+ {
+ var mode = ThemeAppearanceValues.ResolveEffectiveSystemMaterialMode(context.SystemMaterialMode);
+ return mode switch
+ {
+ ThemeAppearanceValues.MaterialAcrylic => context.IsNightMode ? (byte)0x58 : (byte)0x4C,
+ ThemeAppearanceValues.MaterialMica => context.IsNightMode ? (byte)0x50 : (byte)0x44,
+ _ => (byte)0x00
+ };
+ }
}
diff --git a/LanMountainDesktop/Services/IMaterialColorService.cs b/LanMountainDesktop/Services/IMaterialColorService.cs
new file mode 100644
index 0000000..03711d7
--- /dev/null
+++ b/LanMountainDesktop/Services/IMaterialColorService.cs
@@ -0,0 +1,23 @@
+using System;
+using Avalonia.Controls;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.Services.Settings;
+
+namespace LanMountainDesktop.Services;
+
+public interface IMaterialColorService
+{
+ MaterialColorSnapshot GetMaterialColorSnapshot();
+
+ MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState);
+
+ event EventHandler? MaterialColorChanged;
+
+ void ApplyThemeResources(IResourceDictionary resources);
+
+ MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role);
+
+ void ApplyWindowMaterial(Window window, MaterialSurfaceRole role);
+
+ void RefreshWallpaperColors();
+}
diff --git a/LanMountainDesktop/Services/IMusicControlService.cs b/LanMountainDesktop/Services/IMusicControlService.cs
index 307642b..f4e986b 100644
--- a/LanMountainDesktop/Services/IMusicControlService.cs
+++ b/LanMountainDesktop/Services/IMusicControlService.cs
@@ -1,9 +1,19 @@
-using System;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
+public enum MusicPlatform
+{
+ Unknown = 0,
+ Windows = 1,
+ Linux = 2
+}
+
public enum MusicPlaybackStatus
{
Unknown = 0,
@@ -17,8 +27,11 @@ public enum MusicPlaybackStatus
public sealed record MusicPlaybackState(
bool IsSupported,
bool HasSession,
+ MusicPlatform Platform,
+ string SessionId,
string SourceAppId,
string SourceAppName,
+ string SourceExecutableOrBusName,
string Title,
string Artist,
string AlbumTitle,
@@ -28,15 +41,22 @@ public sealed record MusicPlaybackState(
MusicPlaybackStatus PlaybackStatus,
bool CanPlayPause,
bool CanSkipPrevious,
- bool CanSkipNext)
+ bool CanSkipNext,
+ bool CanLaunch,
+ bool IsStale,
+ string StatusMessage,
+ DateTimeOffset UpdatedAtUtc)
{
- public static MusicPlaybackState Unsupported()
+ public static MusicPlaybackState Unsupported(string statusMessage = "Music control is not supported on this platform.")
{
return new MusicPlaybackState(
IsSupported: false,
HasSession: false,
+ Platform: MusicPlatform.Unknown,
+ SessionId: string.Empty,
SourceAppId: string.Empty,
SourceAppName: string.Empty,
+ SourceExecutableOrBusName: string.Empty,
Title: string.Empty,
Artist: string.Empty,
AlbumTitle: string.Empty,
@@ -46,16 +66,26 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
- CanSkipNext: false);
+ CanSkipNext: false,
+ CanLaunch: false,
+ IsStale: false,
+ StatusMessage: statusMessage,
+ UpdatedAtUtc: DateTimeOffset.UtcNow);
}
- public static MusicPlaybackState NoSession(bool isSupported = true)
+ public static MusicPlaybackState NoSession(
+ bool isSupported = true,
+ MusicPlatform platform = MusicPlatform.Unknown,
+ string statusMessage = "No active media session.")
{
return new MusicPlaybackState(
IsSupported: isSupported,
HasSession: false,
+ Platform: platform,
+ SessionId: string.Empty,
SourceAppId: string.Empty,
SourceAppName: string.Empty,
+ SourceExecutableOrBusName: string.Empty,
Title: string.Empty,
Artist: string.Empty,
AlbumTitle: string.Empty,
@@ -65,12 +95,35 @@ public sealed record MusicPlaybackState(
PlaybackStatus: MusicPlaybackStatus.Unknown,
CanPlayPause: false,
CanSkipPrevious: false,
- CanSkipNext: false);
+ CanSkipNext: false,
+ CanLaunch: false,
+ IsStale: false,
+ StatusMessage: statusMessage,
+ UpdatedAtUtc: DateTimeOffset.UtcNow);
}
}
+public interface IMusicSessionProvider : IDisposable
+{
+ MusicPlatform Platform { get; }
+
+ event EventHandler? SessionsChanged;
+
+ Task> GetSessionsAsync(CancellationToken cancellationToken = default);
+
+ Task TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default);
+
+ Task SkipNextAsync(string sessionId, CancellationToken cancellationToken = default);
+
+ Task SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default);
+
+ Task LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default);
+}
+
public interface IMusicControlService
{
+ event EventHandler? StateChanged;
+
Task GetCurrentStateAsync(CancellationToken cancellationToken = default);
Task TogglePlayPauseAsync(CancellationToken cancellationToken = default);
@@ -82,40 +135,116 @@ public interface IMusicControlService
Task LaunchSourceAppAsync(CancellationToken cancellationToken = default);
}
+public sealed class MusicControlService : IMusicControlService, IDisposable
+{
+ private readonly IMusicSessionProvider _provider;
+ private MusicPlaybackState _currentState = MusicPlaybackState.NoSession();
+
+ public MusicControlService(IMusicSessionProvider provider)
+ {
+ _provider = provider;
+ _provider.SessionsChanged += OnProviderSessionsChanged;
+ }
+
+ public event EventHandler? StateChanged;
+
+ public async Task GetCurrentStateAsync(CancellationToken cancellationToken = default)
+ {
+ var sessions = await _provider.GetSessionsAsync(cancellationToken).ConfigureAwait(false);
+ _currentState = SelectCurrentSession(sessions, _provider.Platform);
+ return _currentState;
+ }
+
+ public Task TogglePlayPauseAsync(CancellationToken cancellationToken = default)
+ => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.TogglePlayPauseAsync(sessionId, token), cancellationToken);
+
+ public Task SkipNextAsync(CancellationToken cancellationToken = default)
+ => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipNextAsync(sessionId, token), cancellationToken);
+
+ public Task SkipPreviousAsync(CancellationToken cancellationToken = default)
+ => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.SkipPreviousAsync(sessionId, token), cancellationToken);
+
+ public Task LaunchSourceAppAsync(CancellationToken cancellationToken = default)
+ => ExecuteOnCurrentSessionAsync((sessionId, token) => _provider.LaunchSourceAppAsync(sessionId, token), cancellationToken);
+
+ internal static MusicPlaybackState SelectCurrentSession(IReadOnlyList sessions, MusicPlatform platform)
+ {
+ if (sessions.Count == 0)
+ {
+ return MusicPlaybackState.NoSession(isSupported: true, platform: platform);
+ }
+
+ return sessions
+ .OrderByDescending(session => session.PlaybackStatus == MusicPlaybackStatus.Playing)
+ .ThenByDescending(session => session.UpdatedAtUtc)
+ .First();
+ }
+
+ public void Dispose()
+ {
+ _provider.SessionsChanged -= OnProviderSessionsChanged;
+ _provider.Dispose();
+ }
+
+ private async Task ExecuteOnCurrentSessionAsync(
+ Func> command,
+ CancellationToken cancellationToken)
+ {
+ var state = _currentState.HasSession
+ ? _currentState
+ : await GetCurrentStateAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!state.IsSupported || !state.HasSession || string.IsNullOrWhiteSpace(state.SessionId))
+ {
+ return false;
+ }
+
+ return await command(state.SessionId, cancellationToken).ConfigureAwait(false);
+ }
+
+ private void OnProviderSessionsChanged(object? sender, EventArgs e)
+ => StateChanged?.Invoke(this, EventArgs.Empty);
+}
+
public static class MusicControlServiceFactory
{
public static IMusicControlService CreateDefault()
{
- return OperatingSystem.IsWindows()
- ? new WindowsSmtcMusicControlService()
- : new NoOpMusicControlService();
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return new MusicControlService(new WindowsSmtcMusicControlService());
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return new MusicControlService(new LinuxMprisMusicSessionProvider());
+ }
+
+ return new MusicControlService(new NoOpMusicSessionProvider());
}
}
-internal sealed class NoOpMusicControlService : IMusicControlService
+internal sealed class NoOpMusicSessionProvider : IMusicSessionProvider
{
- public Task GetCurrentStateAsync(CancellationToken cancellationToken = default)
- {
- return Task.FromResult(MusicPlaybackState.Unsupported());
- }
+ public MusicPlatform Platform => MusicPlatform.Unknown;
- public Task TogglePlayPauseAsync(CancellationToken cancellationToken = default)
- {
- return Task.FromResult(false);
- }
+ public event EventHandler? SessionsChanged;
- public Task SkipNextAsync(CancellationToken cancellationToken = default)
- {
- return Task.FromResult(false);
- }
+ public Task> GetSessionsAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult>([MusicPlaybackState.Unsupported()]);
- public Task SkipPreviousAsync(CancellationToken cancellationToken = default)
- {
- return Task.FromResult(false);
- }
+ public Task TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
+ => Task.FromResult(false);
- public Task LaunchSourceAppAsync(CancellationToken cancellationToken = default)
- {
- return Task.FromResult(false);
- }
+ public Task SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
+ => Task.FromResult(false);
+
+ public Task SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default)
+ => Task.FromResult(false);
+
+ public Task LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default)
+ => Task.FromResult(false);
+
+ public void Dispose()
+ => SessionsChanged = null;
}
diff --git a/LanMountainDesktop/Services/IWhiteboardNotePersistenceService.cs b/LanMountainDesktop/Services/IWhiteboardNotePersistenceService.cs
index 44dfe8d..b02a674 100644
--- a/LanMountainDesktop/Services/IWhiteboardNotePersistenceService.cs
+++ b/LanMountainDesktop/Services/IWhiteboardNotePersistenceService.cs
@@ -7,7 +7,7 @@ public interface IWhiteboardNotePersistenceService
{
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
- void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
+ bool SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
bool DeleteNote(string componentId, string? placementId);
diff --git a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs b/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
deleted file mode 100644
index 983a0f7..0000000
--- a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
+++ /dev/null
@@ -1,95 +0,0 @@
-using System.Buffers;
-using System.Diagnostics;
-using System.IO.Pipes;
-using System.Text.Json;
-using LanMountainDesktop.Shared.Contracts.Launcher;
-
-namespace LanMountainDesktop.Services.Launcher;
-
-///
-/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。
-///
-public class LauncherIpcClient : IDisposable
-{
- private static readonly JsonSerializerOptions StartupProgressJsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase
- };
-
- private const int LengthPrefixSize = 4;
-
- private NamedPipeClientStream? _pipeClient;
- private bool _isConnected;
- private readonly object _writeLock = new();
-
- public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
-
- public async Task ConnectAsync(CancellationToken cancellationToken = default)
- {
- try
- {
- _pipeClient = new NamedPipeClientStream(
- ".",
- LauncherIpcConstants.PipeName,
- PipeDirection.Out);
-
- await _pipeClient.ConnectAsync(5000, cancellationToken);
- _isConnected = true;
- return true;
- }
- catch (TimeoutException)
- {
- return false;
- }
- catch (Exception ex)
- {
- AppLogger.Warn("LauncherIpc", $"Failed to connect to Launcher IPC: {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);
-
- lock (_writeLock)
- {
- _pipeClient.Write(lengthPrefix, 0, LengthPrefixSize);
- _pipeClient.Write(payload, 0, payload.Length);
- _pipeClient.Flush();
- }
-
- await Task.CompletedTask;
- }
- catch (IOException)
- {
- _isConnected = false;
- }
- catch (Exception ex)
- {
- AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
- _isConnected = false;
- }
- }
-
- public static bool IsLaunchedByLauncher()
- {
- return LauncherRuntimeMetadata.GetLauncherProcessId(Environment.GetCommandLineArgs()) is not null;
- }
-
- public void Dispose()
- {
- _isConnected = false;
- _pipeClient?.Dispose();
- }
-}
diff --git a/LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs b/LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs
new file mode 100644
index 0000000..6096c2d
--- /dev/null
+++ b/LanMountainDesktop/Services/LinuxMprisMusicSessionProvider.cs
@@ -0,0 +1,477 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Tmds.DBus.Protocol;
+
+namespace LanMountainDesktop.Services;
+
+internal sealed class LinuxMprisMusicSessionProvider : IMusicSessionProvider
+{
+ private const string MprisPrefix = "org.mpris.MediaPlayer2.";
+ private static readonly Regex StringValueRegex = new("\"(?(?:\\\\.|[^\"])*)\"", RegexOptions.Compiled);
+ private static readonly Regex Int64ValueRegex = new(@"int64\s+(?-?\d+)", RegexOptions.Compiled);
+ private static readonly Regex BooleanValueRegex = new(@"boolean\s+(?true|false)", RegexOptions.Compiled);
+ private static readonly Regex ArrayStringRegex = new(@"string\s+""(?(?:\\.|[^""])*)""", RegexOptions.Compiled);
+
+ private readonly CancellationTokenSource _disposeCts = new();
+ private readonly Dictionary _lastSeen = new(StringComparer.Ordinal);
+
+ private IDisposable? _nameOwnerChangedWatcher;
+
+ public MusicPlatform Platform => MusicPlatform.Linux;
+
+ public event EventHandler? SessionsChanged;
+
+ public async Task> GetSessionsAsync(CancellationToken cancellationToken = default)
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return [MusicPlaybackState.Unsupported("Linux MPRIS is only available on Linux.")];
+ }
+
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS")))
+ {
+ return [MusicPlaybackState.Unsupported("DBUS_SESSION_BUS_ADDRESS is not set; MPRIS cannot be reached.")];
+ }
+
+ try
+ {
+ await EnsureSignalWatchAsync(cancellationToken).ConfigureAwait(false);
+ var names = await ListMprisNamesAsync(cancellationToken).ConfigureAwait(false);
+ var sessions = new List();
+ foreach (var name in names)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var session = await ReadSessionAsync(name, cancellationToken).ConfigureAwait(false);
+ if (session is not null)
+ {
+ sessions.Add(session);
+ }
+ }
+
+ return sessions;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return [MusicPlaybackState.Unsupported($"Linux MPRIS read failed: {ex.Message}")];
+ }
+ }
+
+ public Task TogglePlayPauseAsync(string sessionId, CancellationToken cancellationToken = default)
+ => CallPlayerMethodAsync(sessionId, "PlayPause", cancellationToken);
+
+ public Task SkipNextAsync(string sessionId, CancellationToken cancellationToken = default)
+ => CallPlayerMethodAsync(sessionId, "Next", cancellationToken);
+
+ public Task SkipPreviousAsync(string sessionId, CancellationToken cancellationToken = default)
+ => CallPlayerMethodAsync(sessionId, "Previous", cancellationToken);
+
+ public async Task LaunchSourceAppAsync(string sessionId, CancellationToken cancellationToken = default)
+ {
+ if (await CallRootMethodAsync(sessionId, "Raise", cancellationToken).ConfigureAwait(false))
+ {
+ return true;
+ }
+
+ var desktopEntry = sessionId.StartsWith(MprisPrefix, StringComparison.Ordinal)
+ ? sessionId[MprisPrefix.Length..].Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()
+ : sessionId;
+ return !string.IsNullOrWhiteSpace(desktopEntry) && TryLaunchDesktopEntry(desktopEntry);
+ }
+
+ internal static MusicPlaybackState MapMprisSession(
+ string busName,
+ string identity,
+ string playbackStatus,
+ string metadataText,
+ long positionMicroseconds,
+ bool canPlay,
+ bool canPause,
+ bool canGoNext,
+ bool canGoPrevious,
+ bool canControl,
+ DateTimeOffset lastSeen)
+ {
+ var metadata = ParseMetadata(metadataText);
+ var title = metadata.TryGetValue("xesam:title", out var mappedTitle) ? mappedTitle : string.Empty;
+ var album = metadata.TryGetValue("xesam:album", out var mappedAlbum) ? mappedAlbum : string.Empty;
+ var artist = metadata.TryGetValue("xesam:artist", out var mappedArtist) ? mappedArtist : string.Empty;
+ var artUrl = metadata.TryGetValue("mpris:artUrl", out var mappedArtUrl) ? mappedArtUrl : string.Empty;
+ var duration = metadata.TryGetValue("mpris:length", out var lengthText) &&
+ long.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var lengthUs) &&
+ lengthUs > 0
+ ? TimeSpan.FromMilliseconds(lengthUs / 1000d)
+ : TimeSpan.Zero;
+ var position = positionMicroseconds > 0
+ ? TimeSpan.FromMilliseconds(positionMicroseconds / 1000d)
+ : TimeSpan.Zero;
+ if (duration > TimeSpan.Zero && position > duration)
+ {
+ position = duration;
+ }
+
+ var displayName = string.IsNullOrWhiteSpace(identity)
+ ? SimplifyBusName(busName)
+ : identity.Trim();
+ var thumbnailBytes = TryReadArtUrlBytes(artUrl);
+
+ return new MusicPlaybackState(
+ IsSupported: true,
+ HasSession: true,
+ Platform: MusicPlatform.Linux,
+ SessionId: busName,
+ SourceAppId: SimplifyBusName(busName),
+ SourceAppName: displayName,
+ SourceExecutableOrBusName: busName,
+ Title: title,
+ Artist: artist,
+ AlbumTitle: album,
+ ThumbnailBytes: thumbnailBytes,
+ Position: position,
+ Duration: duration,
+ PlaybackStatus: MapPlaybackStatus(playbackStatus),
+ CanPlayPause: canControl && (canPlay || canPause),
+ CanSkipPrevious: canControl && canGoPrevious,
+ CanSkipNext: canControl && canGoNext,
+ CanLaunch: true,
+ IsStale: false,
+ StatusMessage: string.Empty,
+ UpdatedAtUtc: lastSeen);
+ }
+
+ internal static Dictionary ParseMetadata(string text)
+ {
+ var metadata = new Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return metadata;
+ }
+
+ var keys = new[] { "xesam:title", "xesam:artist", "xesam:album", "mpris:length", "mpris:artUrl" };
+ foreach (var key in keys)
+ {
+ var keyIndex = text.IndexOf($"\"{key}\"", StringComparison.Ordinal);
+ if (keyIndex < 0)
+ {
+ continue;
+ }
+
+ var tail = text[keyIndex..];
+ var nextEntryIndex = tail.IndexOf("dict entry", key.Length + 2, StringComparison.Ordinal);
+ if (nextEntryIndex > 0)
+ {
+ tail = tail[..nextEntryIndex];
+ }
+ if (key == "mpris:length")
+ {
+ var intMatch = Int64ValueRegex.Match(tail);
+ if (intMatch.Success)
+ {
+ metadata[key] = intMatch.Groups["value"].Value;
+ }
+
+ continue;
+ }
+
+ if (key == "xesam:artist")
+ {
+ var values = ArrayStringRegex.Matches(tail)
+ .Cast()
+ .Select(match => Unescape(match.Groups["value"].Value))
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Distinct(StringComparer.Ordinal)
+ .Take(3)
+ .ToArray();
+ if (values.Length > 0)
+ {
+ metadata[key] = string.Join(", ", values);
+ continue;
+ }
+ }
+
+ var valueMatches = StringValueRegex.Matches(tail);
+ if (valueMatches.Count >= 2)
+ {
+ metadata[key] = Unescape(valueMatches[1].Groups["value"].Value);
+ }
+ }
+
+ return metadata;
+ }
+
+ public void Dispose()
+ {
+ _disposeCts.Cancel();
+ _nameOwnerChangedWatcher?.Dispose();
+ _disposeCts.Dispose();
+ }
+
+ private async Task EnsureSignalWatchAsync(CancellationToken cancellationToken)
+ {
+ if (_nameOwnerChangedWatcher is not null)
+ {
+ return;
+ }
+
+ try
+ {
+ await DBusConnection.Session.ConnectAsync().ConfigureAwait(false);
+ _nameOwnerChangedWatcher = await DBusConnection.Session.WatchSignalAsync(
+ "org.freedesktop.DBus",
+ "/org/freedesktop/DBus",
+ "org.freedesktop.DBus",
+ "NameOwnerChanged",
+ ex =>
+ {
+ if (ex is null || !ActionException.IsObserverDisposed(ex))
+ {
+ SessionsChanged?.Invoke(this, EventArgs.Empty);
+ }
+ },
+ this,
+ emitOnCapturedContext: false,
+ ObserverFlags.None).ConfigureAwait(false);
+ }
+ catch
+ {
+ _nameOwnerChangedWatcher = null;
+ }
+ }
+
+ private static async Task> ListMprisNamesAsync(CancellationToken cancellationToken)
+ {
+ await DBusConnection.Session.ConnectAsync().ConfigureAwait(false);
+ var names = await DBusConnection.Session.ListServicesAsync().ConfigureAwait(false);
+ return names
+ .Where(name => name.StartsWith(MprisPrefix, StringComparison.Ordinal))
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ private async Task ReadSessionAsync(string busName, CancellationToken cancellationToken)
+ {
+ var identity = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2", "Identity", cancellationToken).ConfigureAwait(false);
+ var playbackStatus = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "PlaybackStatus", cancellationToken).ConfigureAwait(false);
+ var metadata = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "Metadata", cancellationToken).ConfigureAwait(false);
+ var positionText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "Position", cancellationToken).ConfigureAwait(false);
+ var canPlayText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanPlay", cancellationToken).ConfigureAwait(false);
+ var canPauseText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanPause", cancellationToken).ConfigureAwait(false);
+ var canGoNextText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanGoNext", cancellationToken).ConfigureAwait(false);
+ var canGoPreviousText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanGoPrevious", cancellationToken).ConfigureAwait(false);
+ var canControlText = await GetPropertyTextAsync(busName, "org.mpris.MediaPlayer2.Player", "CanControl", cancellationToken).ConfigureAwait(false);
+
+ var lastSeen = DateTimeOffset.UtcNow;
+ _lastSeen[busName] = lastSeen;
+
+ return MapMprisSession(
+ busName,
+ ExtractFirstString(identity),
+ ExtractFirstString(playbackStatus),
+ metadata,
+ ExtractFirstInt64(positionText),
+ ExtractBool(canPlayText),
+ ExtractBool(canPauseText),
+ ExtractBool(canGoNextText),
+ ExtractBool(canGoPreviousText),
+ ExtractBool(canControlText, defaultValue: true),
+ lastSeen);
+ }
+
+ private static async Task GetPropertyTextAsync(
+ string busName,
+ string interfaceName,
+ string propertyName,
+ CancellationToken cancellationToken)
+ {
+ var result = await RunDbusSendAsync(
+ [
+ "--session",
+ "--print-reply",
+ $"--dest={busName}",
+ "/org/mpris/MediaPlayer2",
+ "org.freedesktop.DBus.Properties.Get",
+ $"string:{interfaceName}",
+ $"string:{propertyName}"
+ ],
+ cancellationToken).ConfigureAwait(false);
+ return result;
+ }
+
+ private static Task CallPlayerMethodAsync(string busName, string methodName, CancellationToken cancellationToken)
+ => CallMethodAsync(busName, $"org.mpris.MediaPlayer2.Player.{methodName}", cancellationToken);
+
+ private static Task CallRootMethodAsync(string busName, string methodName, CancellationToken cancellationToken)
+ => CallMethodAsync(busName, $"org.mpris.MediaPlayer2.{methodName}", cancellationToken);
+
+ private static async Task CallMethodAsync(string busName, string methodName, CancellationToken cancellationToken)
+ {
+ try
+ {
+ _ = await RunDbusSendAsync(
+ [
+ "--session",
+ "--print-reply",
+ $"--dest={busName}",
+ "/org/mpris/MediaPlayer2",
+ methodName
+ ],
+ cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static async Task RunDbusSendAsync(IReadOnlyList arguments, CancellationToken cancellationToken)
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "dbus-send",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }
+ };
+
+ foreach (var argument in arguments)
+ {
+ process.StartInfo.ArgumentList.Add(argument);
+ }
+
+ if (!process.Start())
+ {
+ throw new InvalidOperationException("Failed to start dbus-send.");
+ }
+
+ var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ var output = await outputTask.ConfigureAwait(false);
+ var error = await errorTask.ConfigureAwait(false);
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? $"dbus-send exited with {process.ExitCode}." : error.Trim());
+ }
+
+ return output;
+ }
+
+ private static bool TryLaunchDesktopEntry(string desktopEntry)
+ {
+ var normalized = desktopEntry.EndsWith(".desktop", StringComparison.Ordinal)
+ ? desktopEntry
+ : $"{desktopEntry}.desktop";
+ var candidates = new[]
+ {
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/applications", normalized),
+ Path.Combine("/usr/share/applications", normalized)
+ };
+
+ var desktopFile = candidates.FirstOrDefault(File.Exists);
+ if (desktopFile is null)
+ {
+ return false;
+ }
+
+ var execLine = File.ReadLines(desktopFile)
+ .FirstOrDefault(line => line.StartsWith("Exec=", StringComparison.Ordinal));
+ if (string.IsNullOrWhiteSpace(execLine))
+ {
+ return false;
+ }
+
+ var command = Regex.Replace(execLine[5..], @"\s+%[fFuUdDnNickvm]", string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(command))
+ {
+ return false;
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "/bin/sh",
+ ArgumentList = { "-lc", command },
+ UseShellExecute = false,
+ CreateNoWindow = true
+ });
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string ExtractFirstString(string text)
+ {
+ var match = StringValueRegex.Match(text);
+ return match.Success ? Unescape(match.Groups["value"].Value) : string.Empty;
+ }
+
+ private static long ExtractFirstInt64(string text)
+ {
+ var match = Int64ValueRegex.Match(text);
+ return match.Success && long.TryParse(match.Groups["value"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : 0;
+ }
+
+ private static bool ExtractBool(string text, bool defaultValue = false)
+ {
+ var match = BooleanValueRegex.Match(text);
+ return match.Success
+ ? string.Equals(match.Groups["value"].Value, "true", StringComparison.OrdinalIgnoreCase)
+ : defaultValue;
+ }
+
+ private static MusicPlaybackStatus MapPlaybackStatus(string status)
+ => status.Trim() switch
+ {
+ "Playing" => MusicPlaybackStatus.Playing,
+ "Paused" => MusicPlaybackStatus.Paused,
+ "Stopped" => MusicPlaybackStatus.Stopped,
+ _ => MusicPlaybackStatus.Unknown
+ };
+
+ private static string SimplifyBusName(string busName)
+ => busName.StartsWith(MprisPrefix, StringComparison.Ordinal)
+ ? busName[MprisPrefix.Length..].Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? busName
+ : busName;
+
+ private static byte[]? TryReadArtUrlBytes(string artUrl)
+ {
+ if (string.IsNullOrWhiteSpace(artUrl) ||
+ !Uri.TryCreate(artUrl, UriKind.Absolute, out var uri) ||
+ !string.Equals(uri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ try
+ {
+ return File.Exists(uri.LocalPath) ? File.ReadAllBytes(uri.LocalPath) : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static string Unescape(string value)
+ => value
+ .Replace("\\\"", "\"", StringComparison.Ordinal)
+ .Replace("\\n", "\n", StringComparison.Ordinal)
+ .Replace("\\\\", "\\", StringComparison.Ordinal);
+}
diff --git a/LanMountainDesktop/Services/LinuxNotificationListener.cs b/LanMountainDesktop/Services/LinuxNotificationListener.cs
index 4c298b0..97e129a 100644
--- a/LanMountainDesktop/Services/LinuxNotificationListener.cs
+++ b/LanMountainDesktop/Services/LinuxNotificationListener.cs
@@ -1,131 +1,214 @@
using System;
+using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
-///
-/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications
-///
[SupportedOSPlatform("linux")]
-internal sealed class LinuxNotificationListener : IDisposable
+internal sealed class LinuxNotificationListener : IPlatformNotificationListener
{
- private readonly NotificationListenerService _parent;
- private CancellationTokenSource? _cts;
- private bool _isRunning;
+ private static readonly Regex DbusStringRegex = new("^\\s*string\\s+\"(?.*)\"\\s*$", RegexOptions.Compiled);
+ private static readonly Regex DbusUIntRegex = new("^\\s*uint32\\s+(?\\d+)\\s*$", RegexOptions.Compiled);
+ private static readonly Regex DesktopEntryHintRegex = new("\"desktop-entry\"\\s+variant\\s+string\\s+\"(?[^\"]+)\"", RegexOptions.Compiled);
+ private static readonly Regex ImagePathHintRegex = new("\"image-path\"\\s+variant\\s+string\\s+\"(?[^\"]+)\"", RegexOptions.Compiled);
- public LinuxNotificationListener(NotificationListenerService parent)
+ private readonly NotificationListenerService _parent;
+ private readonly string _requestedMode;
+ private readonly CancellationTokenSource _cts = new();
+ private Process? _monitorProcess;
+ private Task? _monitorTask;
+ private uint _nextSyntheticId = 1;
+
+ public LinuxNotificationListener(NotificationListenerService parent, string requestedMode)
{
_parent = parent;
+ _requestedMode = string.IsNullOrWhiteSpace(requestedMode) ? "ProxyDaemon" : requestedMode;
}
- ///
- /// 初始化并启动DBus监听
- ///
- public async Task InitializeAsync()
+ public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
- try
+ if (!OperatingSystem.IsLinux())
{
- // 检查DBus环境变量
- var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS");
- if (string.IsNullOrEmpty(dbusSessionBus))
- {
- Console.WriteLine("[NotificationBox] DBus Session Bus 环境变量未设置");
- return false;
- }
-
- // 检查通知守护进程是否运行
- // 通过检查常见进程名
- var hasNotificationDaemon = await CheckNotificationDaemonAsync();
- if (!hasNotificationDaemon)
- {
- Console.WriteLine("[NotificationBox] 未检测到通知守护进程,消息盒子功能可能不可用");
- // 仍然返回true,因为守护进程可能在之后启动
- }
-
- _cts = new CancellationTokenSource();
- _ = StartListeningAsync(_cts.Token);
-
- Console.WriteLine("[NotificationBox] Linux通知监听已启动");
- return true;
+ return new NotificationBoxStatus(NotificationBoxServiceState.Unsupported, "当前平台不是 Linux。", "Linux");
}
- catch (Exception ex)
+
+ var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS");
+ if (string.IsNullOrEmpty(dbusSessionBus))
{
- Console.WriteLine($"[NotificationBox] Linux通知监听初始化失败: {ex.Message}");
- return false;
+ return new NotificationBoxStatus(
+ NotificationBoxServiceState.Unsupported,
+ "DBus Session Bus 环境变量未设置,无法监听 Linux 通知。",
+ _requestedMode);
}
+
+ var hasMonitorTool = CommandExists("dbus-monitor");
+ if (!hasMonitorTool)
+ {
+ return new NotificationBoxStatus(
+ NotificationBoxServiceState.Unsupported,
+ "未找到 dbus-monitor,无法启用 Linux 通知旁路监听。",
+ _requestedMode);
+ }
+
+ var mode = _requestedMode.Equals("PassiveMonitor", StringComparison.OrdinalIgnoreCase)
+ ? "PassiveMonitor"
+ : "ProxyDaemon";
+
+ var daemonRunning = await CheckNotificationDaemonAsync(cancellationToken).ConfigureAwait(false);
+ var statusMessage = mode == "ProxyDaemon" && daemonRunning
+ ? "系统通知守护进程已占用 org.freedesktop.Notifications,已以旁路监听方式运行。"
+ : mode == "ProxyDaemon"
+ ? "Linux 通知代理模式已启动;未检测到现有通知守护进程。"
+ : "Linux 通知旁路监听已启动。";
+
+ StartDbusMonitor(mode);
+
+ return new NotificationBoxStatus(
+ mode == "ProxyDaemon" && daemonRunning ? NotificationBoxServiceState.Degraded : NotificationBoxServiceState.Running,
+ statusMessage,
+ mode);
}
- private async Task CheckNotificationDaemonAsync()
+ private void StartDbusMonitor(string mode)
{
- try
+ var startInfo = new ProcessStartInfo
{
- // 检查常见通知守护进程
- var processNames = new[] { "gnome-shell", "kded5", "dunst", "mako", "swaync" };
- foreach (var name in processNames)
- {
- var psi = new System.Diagnostics.ProcessStartInfo
- {
- FileName = "pgrep",
- Arguments = $"-x {name}",
- RedirectStandardOutput = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
+ FileName = "dbus-monitor",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ startInfo.ArgumentList.Add("--session");
+ startInfo.ArgumentList.Add("interface='org.freedesktop.Notifications'");
- using var process = System.Diagnostics.Process.Start(psi);
- if (process != null)
- {
- await process.WaitForExitAsync();
- if (process.ExitCode == 0)
- {
- return true;
- }
- }
- }
- return false;
- }
- catch
+ _monitorProcess = Process.Start(startInfo);
+ if (_monitorProcess is null)
{
- return false;
+ throw new InvalidOperationException("Failed to start dbus-monitor.");
}
+
+ _monitorTask = Task.Run(() => ReadMonitorOutputAsync(_monitorProcess, mode, _cts.Token), CancellationToken.None);
}
- private async Task StartListeningAsync(CancellationToken ct)
+ private async Task ReadMonitorOutputAsync(Process process, string mode, CancellationToken cancellationToken)
{
- _isRunning = true;
+ var capture = new List();
+ var inNotify = false;
- try
+ while (!cancellationToken.IsCancellationRequested && !process.HasExited)
{
- // 注意:Tmds.DBus.Protocol 是低层API
- // 这里使用简化方案,实际生产环境需要完整的DBus信号订阅实现
- // 当前版本为框架实现,后续可以完善DBus监听逻辑
-
- while (!ct.IsCancellationRequested && _isRunning)
+ var line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ if (line is null)
{
- try
- {
- await Task.Delay(1000, ct);
- }
- catch (OperationCanceledException)
- {
- break;
- }
+ break;
+ }
+
+ if (line.Contains("member=Notify", StringComparison.Ordinal))
+ {
+ capture.Clear();
+ inNotify = true;
+ continue;
+ }
+
+ if (!inNotify)
+ {
+ if (line.Contains("member=NotificationClosed", StringComparison.Ordinal) ||
+ line.Contains("member=CloseNotification", StringComparison.Ordinal))
+ {
+ capture.Clear();
+ capture.Add(line);
+ inNotify = false;
+ }
+ continue;
+ }
+
+ if (line.StartsWith("method ", StringComparison.Ordinal) ||
+ line.StartsWith("signal ", StringComparison.Ordinal))
+ {
+ TryParseNotify(capture, mode);
+ capture.Clear();
+ inNotify = line.Contains("member=Notify", StringComparison.Ordinal);
+ continue;
+ }
+
+ capture.Add(line);
+ if (capture.Count > 40)
+ {
+ TryParseNotify(capture, mode);
+ capture.Clear();
+ inNotify = false;
}
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
}
}
- ///
- /// 处理接收到的通知(供DBus信号处理器调用)
- ///
+ private void TryParseNotify(IReadOnlyList lines, string mode)
+ {
+ if (lines.Count == 0)
+ {
+ return;
+ }
+
+ var strings = lines
+ .Select(line => DbusStringRegex.Match(line))
+ .Where(match => match.Success)
+ .Select(match => UnescapeDbusString(match.Groups["value"].Value))
+ .ToList();
+
+ if (strings.Count < 4)
+ {
+ return;
+ }
+
+ var appName = strings[0];
+ var appIcon = strings[1];
+ var summary = strings[2];
+ var body = strings[3];
+ var desktopEntry = TryMatchHint(lines, DesktopEntryHintRegex);
+ var imagePath = TryMatchHint(lines, ImagePathHintRegex);
+
+ var sourceId = lines
+ .Select(line => DbusUIntRegex.Match(line))
+ .Where(match => match.Success)
+ .Select(match => match.Groups["value"].Value)
+ .Skip(1)
+ .FirstOrDefault();
+
+ if (string.IsNullOrWhiteSpace(sourceId))
+ {
+ sourceId = (_nextSyntheticId++).ToString();
+ }
+
+ var notification = new NotificationItem
+ {
+ Id = $"linux:{sourceId}",
+ SourceNotificationId = sourceId,
+ Platform = "Linux",
+ CaptureMode = mode,
+ AppId = !string.IsNullOrWhiteSpace(desktopEntry)
+ ? desktopEntry
+ : NormalizeAppId(appName),
+ AppName = string.IsNullOrWhiteSpace(appName) ? "Linux 应用" : appName,
+ Title = StripHtmlTags(summary),
+ Content = StripHtmlTags(body),
+ AppIconPath = ResolveIconPath(!string.IsNullOrWhiteSpace(imagePath) ? imagePath : appIcon, appName),
+ DesktopEntryId = string.IsNullOrWhiteSpace(desktopEntry) ? null : $"{desktopEntry}.desktop",
+ LaunchTarget = string.IsNullOrWhiteSpace(desktopEntry) ? null : desktopEntry,
+ CanActivate = !string.IsNullOrWhiteSpace(desktopEntry),
+ ReceivedAtUtc = DateTimeOffset.UtcNow,
+ ReceivedTime = DateTime.Now
+ };
+
+ _parent.AddNotification(notification);
+ }
+
public void HandleNotification(
string appName,
uint replacesId,
@@ -136,30 +219,75 @@ internal sealed class LinuxNotificationListener : IDisposable
object hints,
int expireTimeout)
{
- try
+ var sourceId = replacesId == 0 ? _nextSyntheticId++ : replacesId;
+ var notification = new NotificationItem
{
- var notification = new NotificationItem
- {
- Id = Guid.NewGuid().ToString(),
- AppId = appName.ToLowerInvariant().Replace(" ", ""),
- AppName = appName,
- Title = summary,
- Content = StripHtmlTags(body),
- ReceivedTime = DateTime.Now,
- AppIconPath = ResolveIconPath(appIcon, appName)
- };
+ Id = $"linux:{sourceId}",
+ SourceNotificationId = sourceId.ToString(),
+ Platform = "Linux",
+ CaptureMode = _requestedMode,
+ AppId = NormalizeAppId(appName),
+ AppName = appName,
+ Title = StripHtmlTags(summary),
+ Content = StripHtmlTags(body),
+ AppIconPath = ResolveIconPath(appIcon, appName),
+ ReceivedAtUtc = DateTimeOffset.UtcNow,
+ ReceivedTime = DateTime.Now
+ };
- _parent.AddNotification(notification);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[NotificationBox] 处理通知失败: {ex.Message}");
- }
+ _parent.AddNotification(notification);
+ }
+
+ private static async Task CheckNotificationDaemonAsync(CancellationToken cancellationToken)
+ {
+ var processNames = new[] { "gnome-shell", "plasmashell", "kded5", "dunst", "mako", "swaync", "xfce4-notifyd" };
+ foreach (var name in processNames)
+ {
+ try
+ {
+ using var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = "pgrep",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }.WithArgument("-x").WithArgument(name));
+ if (process is null)
+ {
+ continue;
+ }
+
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ if (process.ExitCode == 0)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ return false;
+ }
+
+ private static bool CommandExists(string command)
+ {
+ var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return pathEntries.Any(path =>
+ {
+ try
+ {
+ return File.Exists(Path.Combine(path, command));
+ }
+ catch
+ {
+ return false;
+ }
+ });
}
- ///
- /// 解析应用图标路径
- ///
private static string? ResolveIconPath(string iconName, string appName)
{
if (string.IsNullOrEmpty(iconName))
@@ -167,13 +295,11 @@ internal sealed class LinuxNotificationListener : IDisposable
return null;
}
- // 如果是绝对路径,直接使用
if (File.Exists(iconName))
{
return iconName;
}
- // 尝试从图标主题中查找
var iconPaths = new[]
{
$"/usr/share/icons/hicolor/48x48/apps/{iconName}.png",
@@ -187,9 +313,6 @@ internal sealed class LinuxNotificationListener : IDisposable
return iconPaths.FirstOrDefault(File.Exists);
}
- ///
- /// 去除HTML标签(通知内容可能包含HTML)
- ///
private static string StripHtmlTags(string html)
{
if (string.IsNullOrEmpty(html))
@@ -197,20 +320,58 @@ internal sealed class LinuxNotificationListener : IDisposable
return string.Empty;
}
- // 简单的HTML标签去除
- var result = html;
- result = System.Text.RegularExpressions.Regex.Replace(result, "<[^>]+>", "");
- result = result.Replace("<", "<");
- result = result.Replace(">", ">");
- result = result.Replace("&", "&");
- result = result.Replace(""", "\"");
- return result.Trim();
+ var result = Regex.Replace(html, "<[^>]+>", string.Empty);
+ return result
+ .Replace("<", "<", StringComparison.Ordinal)
+ .Replace(">", ">", StringComparison.Ordinal)
+ .Replace("&", "&", StringComparison.Ordinal)
+ .Replace(""", "\"", StringComparison.Ordinal)
+ .Trim();
}
+ private static string NormalizeAppId(string appName)
+ => appName.ToLowerInvariant().Replace(" ", string.Empty, StringComparison.Ordinal);
+
+ private static string? TryMatchHint(IEnumerable lines, Regex regex)
+ => lines.Select(line => regex.Match(line))
+ .Where(match => match.Success)
+ .Select(match => UnescapeDbusString(match.Groups["value"].Value))
+ .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
+
+ private static string UnescapeDbusString(string value)
+ => value
+ .Replace("\\\"", "\"", StringComparison.Ordinal)
+ .Replace("\\n", "\n", StringComparison.Ordinal)
+ .Replace("\\\\", "\\", StringComparison.Ordinal);
+
public void Dispose()
{
- _isRunning = false;
- _cts?.Cancel();
- _cts?.Dispose();
+ _cts.Cancel();
+ try
+ {
+ if (_monitorProcess is { HasExited: false })
+ {
+ _monitorProcess.Kill(entireProcessTree: true);
+ }
+
+ _monitorTask?.Wait(TimeSpan.FromSeconds(1));
+ }
+ catch
+ {
+ }
+ finally
+ {
+ _monitorProcess?.Dispose();
+ _cts.Dispose();
+ }
+ }
+}
+
+internal static class ProcessStartInfoArgumentExtensions
+{
+ public static ProcessStartInfo WithArgument(this ProcessStartInfo startInfo, string argument)
+ {
+ startInfo.ArgumentList.Add(argument);
+ return startInfo;
}
}
diff --git a/LanMountainDesktop/Services/MainWindowDesktopLayerService.cs b/LanMountainDesktop/Services/MainWindowDesktopLayerService.cs
new file mode 100644
index 0000000..5ace9ca
--- /dev/null
+++ b/LanMountainDesktop/Services/MainWindowDesktopLayerService.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using Avalonia.Controls;
+
+namespace LanMountainDesktop.Services;
+
+public interface IMainWindowDesktopLayerService
+{
+ bool IsSupported { get; }
+ void EnableOrRefresh(Window window);
+ void Disable(Window window);
+}
+
+public static class MainWindowDesktopLayerServiceFactory
+{
+ private static readonly object Gate = new();
+ private static IMainWindowDesktopLayerService? _instance;
+
+ public static IMainWindowDesktopLayerService GetOrCreate()
+ {
+ lock (Gate)
+ {
+ return _instance ??= OperatingSystem.IsWindows()
+ ? new WindowsMainWindowDesktopLayerService()
+ : new NullMainWindowDesktopLayerService();
+ }
+ }
+}
+
+internal sealed class WindowsMainWindowDesktopLayerService : IMainWindowDesktopLayerService
+{
+ private const int GWL_STYLE = -16;
+ private const int GWL_EXSTYLE = -20;
+
+ private const long WS_CHILD = 0x40000000L;
+ private const long WS_POPUP = 0x80000000L;
+ private const long WS_CAPTION = 0x00C00000L;
+ private const long WS_THICKFRAME = 0x00040000L;
+ private const long WS_MINIMIZEBOX = 0x00020000L;
+ private const long WS_MAXIMIZEBOX = 0x00010000L;
+ private const long WS_SYSMENU = 0x00080000L;
+
+ private const uint SWP_NOSIZE = 0x0001;
+ private const uint SWP_NOMOVE = 0x0002;
+ private const uint SWP_NOACTIVATE = 0x0010;
+ private const uint SWP_SHOWWINDOW = 0x0040;
+ private const uint SWP_FRAMECHANGED = 0x0020;
+
+ private static readonly IntPtr HWND_TOP = IntPtr.Zero;
+ private static readonly IntPtr HWND_BOTTOM = new(1);
+
+ private readonly object _gate = new();
+ private readonly Dictionary _restoreStates = [];
+
+ public bool IsSupported => true;
+
+ public void EnableOrRefresh(Window window)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ var handle = GetWindowHandle(window);
+ if (handle == IntPtr.Zero)
+ {
+ window.Opened -= OnDeferredOpened;
+ window.Opened += OnDeferredOpened;
+ return;
+ }
+
+ EnableOrRefresh(handle);
+ }
+
+ public void Disable(Window window)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+ window.Opened -= OnDeferredOpened;
+
+ var handle = GetWindowHandle(window);
+ if (handle == IntPtr.Zero)
+ {
+ return;
+ }
+
+ WindowRestoreState? restoreState;
+ lock (_gate)
+ {
+ if (!_restoreStates.Remove(handle, out restoreState))
+ {
+ return;
+ }
+ }
+
+ try
+ {
+ _ = SetParent(handle, restoreState.Parent);
+ SetWindowLongPtr(handle, GWL_STYLE, restoreState.Style);
+ SetWindowLongPtr(handle, GWL_EXSTYLE, restoreState.ExStyle);
+ _ = SetWindowPos(
+ handle,
+ HWND_TOP,
+ 0,
+ 0,
+ 0,
+ 0,
+ SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
+ AppLogger.Info("MainWindowDesktopLayer", $"Disabled desktop layer. Window={handle}.");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MainWindowDesktopLayer", $"Failed to disable desktop layer. Window={handle}.", ex);
+ }
+ }
+
+ private void OnDeferredOpened(object? sender, EventArgs e)
+ {
+ if (sender is not Window window)
+ {
+ return;
+ }
+
+ window.Opened -= OnDeferredOpened;
+ EnableOrRefresh(window);
+ }
+
+ private void EnableOrRefresh(IntPtr handle)
+ {
+ if (handle == IntPtr.Zero || !IsWindow(handle))
+ {
+ return;
+ }
+
+ SaveRestoreStateIfNeeded(handle);
+ var desktopHost = ResolveDesktopIconHost();
+ if (desktopHost != IntPtr.Zero && IsWindow(desktopHost))
+ {
+ ApplyDesktopChildStyle(handle);
+ if (GetParent(handle) != desktopHost)
+ {
+ _ = SetParent(handle, desktopHost);
+ }
+
+ _ = SetWindowPos(
+ handle,
+ HWND_TOP,
+ 0,
+ 0,
+ 0,
+ 0,
+ SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
+ AppLogger.Info("MainWindowDesktopLayer", $"Enabled desktop layer. Window={handle}; Host={desktopHost}.");
+ return;
+ }
+
+ _ = SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
+ AppLogger.Warn("MainWindowDesktopLayer", $"Desktop icon host not found. Falling back to HWND_BOTTOM. Window={handle}.");
+ }
+
+ private void SaveRestoreStateIfNeeded(IntPtr handle)
+ {
+ lock (_gate)
+ {
+ if (_restoreStates.ContainsKey(handle))
+ {
+ return;
+ }
+
+ _restoreStates[handle] = new WindowRestoreState(
+ GetParent(handle),
+ GetWindowLongPtr(handle, GWL_STYLE),
+ GetWindowLongPtr(handle, GWL_EXSTYLE));
+ }
+ }
+
+ private static void ApplyDesktopChildStyle(IntPtr handle)
+ {
+ var style = GetWindowLongPtr(handle, GWL_STYLE).ToInt64();
+ style |= WS_CHILD;
+ style &= ~(WS_POPUP | WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU);
+ SetWindowLongPtr(handle, GWL_STYLE, new IntPtr(style));
+ }
+
+ private static IntPtr ResolveDesktopIconHost()
+ {
+ var topLevelWindows = new List();
+ EnumWindows((handle, _) =>
+ {
+ topLevelWindows.Add(handle);
+ return true;
+ }, IntPtr.Zero);
+
+ foreach (var topLevelWindow in topLevelWindows)
+ {
+ var worker = FindWindowEx(topLevelWindow, IntPtr.Zero, "WorkerW", null);
+ if (worker == IntPtr.Zero)
+ {
+ continue;
+ }
+
+ var defView = FindWindowEx(worker, IntPtr.Zero, "SHELLDLL_DefView", null);
+ if (defView != IntPtr.Zero)
+ {
+ return defView;
+ }
+ }
+
+ foreach (var topLevelWindow in topLevelWindows)
+ {
+ var defView = FindWindowEx(topLevelWindow, IntPtr.Zero, "SHELLDLL_DefView", null);
+ if (defView != IntPtr.Zero)
+ {
+ return defView;
+ }
+ }
+
+ return IntPtr.Zero;
+ }
+
+ private static IntPtr GetWindowHandle(Window window)
+ {
+ try
+ {
+ return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
+ }
+ catch
+ {
+ return IntPtr.Zero;
+ }
+ }
+
+ private sealed record WindowRestoreState(IntPtr Parent, IntPtr Style, IntPtr ExStyle);
+
+ private delegate bool EnumWindowsProc(IntPtr handle, IntPtr lParam);
+
+ [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
+ private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
+
+ [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
+ private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetParent(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindow(IntPtr hWnd);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
+}
+
+internal sealed class NullMainWindowDesktopLayerService : IMainWindowDesktopLayerService
+{
+ public bool IsSupported => false;
+
+ public void EnableOrRefresh(Window window)
+ {
+ AppLogger.Info("MainWindowDesktopLayer", "Desktop layer requested on an unsupported platform.");
+ }
+
+ public void Disable(Window window)
+ {
+ }
+}
diff --git a/LanMountainDesktop/Services/MaterialColorService.cs b/LanMountainDesktop/Services/MaterialColorService.cs
new file mode 100644
index 0000000..46ae6ac
--- /dev/null
+++ b/LanMountainDesktop/Services/MaterialColorService.cs
@@ -0,0 +1,645 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.Versioning;
+using System.Threading;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using LanMountainDesktop.Appearance;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+using LanMountainDesktop.Settings.Core;
+using LanMountainDesktop.Shared.Contracts;
+using LanMountainDesktop.Theme;
+using Microsoft.Win32;
+
+namespace LanMountainDesktop.Services;
+
+internal sealed class MaterialColorService : IMaterialColorService, IDisposable
+{
+ private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly IWindowMaterialService _windowMaterialService;
+ private readonly IMaterialSurfaceService _materialSurfaceService;
+ private readonly MonetColorService _monetColorService = new();
+ private readonly WallpaperColorPipeline _wallpaperColorPipeline;
+ private string _liveThemeColorMode;
+ private string _liveSystemMaterialMode;
+ private string? _liveSelectedWallpaperSeed;
+ private string _liveThemeWallpaperColorSource;
+ private bool _liveUseNativeWallpaperChangeEvents;
+ private Timer? _systemWallpaperPollTimer;
+ private string? _lastObservedWallpaperSourceKey;
+ private bool _nativeWallpaperEventsActive;
+ private bool _wallpaperPollingActive;
+
+ public MaterialColorService(
+ ISettingsFacadeService settingsFacade,
+ ISystemWallpaperProvider systemWallpaperProvider,
+ IWindowMaterialService windowMaterialService,
+ IMaterialSurfaceService materialSurfaceService)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
+ _materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
+ _wallpaperColorPipeline = new WallpaperColorPipeline(
+ _settingsFacade,
+ systemWallpaperProvider ?? throw new ArgumentNullException(nameof(systemWallpaperProvider)),
+ _monetColorService,
+ RaiseChanged);
+ var initialThemeState = _settingsFacade.Theme.Get();
+ _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
+ initialThemeState.ThemeColorMode,
+ initialThemeState.ThemeColor);
+ _liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode);
+ _liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed;
+ _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(initialThemeState.ThemeWallpaperColorSource);
+ _liveUseNativeWallpaperChangeEvents = initialThemeState.UseNativeWallpaperChangeEvents;
+ _settingsFacade.Settings.Changed += OnSettingsChanged;
+ ConfigureSystemWallpaperMonitoring(initialThemeState);
+ }
+
+ internal event EventHandler? AppearanceThemeChanged;
+
+ public event EventHandler? MaterialColorChanged;
+
+ public AppearanceThemeSnapshot GetCurrent()
+ {
+ return BuildCurrentSnapshot(queueWallpaperPaletteBuild: true);
+ }
+
+ public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState)
+ {
+ ArgumentNullException.ThrowIfNull(pendingState);
+
+ var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
+ pendingState.ThemeColorMode,
+ pendingState.ThemeColor);
+ var normalizedSystemMaterialMode = ResolveSupportedMaterialMode(pendingState.SystemMaterialMode);
+ return BuildSnapshot(
+ pendingState with
+ {
+ ThemeColorMode = normalizedThemeColorMode,
+ SystemMaterialMode = normalizedSystemMaterialMode
+ },
+ normalizedThemeColorMode,
+ normalizedSystemMaterialMode,
+ pendingState.SelectedWallpaperSeed,
+ queueWallpaperPaletteBuild: true);
+ }
+
+ public MaterialColorSnapshot GetMaterialColorSnapshot()
+ {
+ return CreateMaterialColorSnapshot(GetCurrent());
+ }
+
+ public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState)
+ {
+ return CreateMaterialColorSnapshot(BuildPreview(pendingState));
+ }
+
+ public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role)
+ {
+ var surface = GetMaterialSurface(role);
+ return new MaterialSurfaceSnapshot(
+ role,
+ surface.BackgroundColor,
+ surface.BorderColor,
+ surface.BlurRadius,
+ surface.Opacity);
+ }
+
+ public void RefreshWallpaperColors()
+ {
+ _wallpaperColorPipeline.Clear();
+ _lastObservedWallpaperSourceKey = null;
+ RaiseChanged(queueWallpaperPaletteBuild: true);
+ }
+
+ public void ApplyThemeResources(IResourceDictionary resources)
+ {
+ ArgumentNullException.ThrowIfNull(resources);
+
+ var snapshot = GetCurrent();
+ var context = CreateThemeContext(snapshot);
+ ThemeColorSystemService.ApplyThemeResources(resources, context);
+ GlassEffectService.ApplyGlassResources(resources, context);
+ resources["DesignCornerRadiusMicro"] = snapshot.CornerRadiusTokens.Micro;
+ resources["DesignCornerRadiusXs"] = snapshot.CornerRadiusTokens.Xs;
+ resources["DesignCornerRadiusSm"] = snapshot.CornerRadiusTokens.Sm;
+ resources["DesignCornerRadiusMd"] = snapshot.CornerRadiusTokens.Md;
+ resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg;
+ resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl;
+ resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island;
+ resources["DesignCornerRadiusComponent"] = snapshot.CornerRadiusTokens.Component;
+ }
+
+ public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
+ {
+ var snapshot = GetCurrent();
+ return _materialSurfaceService.GetSurface(CreateThemeContext(snapshot), role);
+ }
+
+ public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ // Avoid hot-switching real backdrops on already-visible windows. This has been
+ // a stability hotspot when users flip theme source/material at runtime.
+ // SettingsWindowBackground 是唯一需要材质与资源同步热切换的宿主角色;其它窗口仍保持「仅创建时」应用以降低风险。
+ if (window.IsVisible && role != MaterialSurfaceRole.SettingsWindowBackground)
+ {
+ return;
+ }
+
+ var snapshot = GetCurrent();
+
+ try
+ {
+ _windowMaterialService.Apply(window, snapshot.SystemMaterialMode);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ "Appearance.WindowMaterial",
+ $"Failed to apply window material '{snapshot.SystemMaterialMode}'. Falling back to none.",
+ ex);
+ _windowMaterialService.Apply(window, ThemeAppearanceValues.MaterialNone);
+ }
+ }
+
+ public void Dispose()
+ {
+ _settingsFacade.Settings.Changed -= OnSettingsChanged;
+ StopSystemWallpaperMonitoring();
+ _systemWallpaperPollTimer?.Dispose();
+ _systemWallpaperPollTimer = null;
+ }
+
+ private AppearanceThemeSnapshot BuildCurrentSnapshot(bool queueWallpaperPaletteBuild)
+ {
+ var themeState = _settingsFacade.Theme.Get();
+ return BuildSnapshot(
+ themeState,
+ _liveThemeColorMode,
+ _liveSystemMaterialMode,
+ _liveSelectedWallpaperSeed,
+ queueWallpaperPaletteBuild);
+ }
+
+ private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
+ {
+ _ = sender;
+
+ if (e.Scope != SettingsScope.App)
+ {
+ return;
+ }
+
+ var changedKeys = e.ChangedKeys?.ToArray();
+ var refreshAll = changedKeys is null || changedKeys.Length == 0;
+ var respondsToThemeColor = string.Equals(
+ _liveThemeColorMode,
+ ThemeAppearanceValues.ColorModeSeedMonet,
+ StringComparison.OrdinalIgnoreCase);
+ var respondsToWallpaper = string.Equals(
+ _liveThemeColorMode,
+ ThemeAppearanceValues.ColorModeWallpaperMonet,
+ StringComparison.OrdinalIgnoreCase);
+
+ if (!refreshAll &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase) &&
+ !(respondsToThemeColor &&
+ changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
+ !(respondsToWallpaper &&
+ (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))))
+ {
+ return;
+ }
+
+ var latestThemeState = _settingsFacade.Theme.Get();
+ _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
+ latestThemeState.ThemeColorMode,
+ latestThemeState.ThemeColor);
+ _liveSystemMaterialMode = ResolveSupportedMaterialMode(latestThemeState.SystemMaterialMode);
+ _liveSelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed;
+ _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(latestThemeState.ThemeWallpaperColorSource);
+ _liveUseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents;
+ ConfigureSystemWallpaperMonitoring(latestThemeState);
+ RaiseChanged(queueWallpaperPaletteBuild: true);
+ }
+
+ private AppearanceThemeSnapshot BuildSnapshot(
+ ThemeAppearanceSettingsState themeState,
+ string themeColorMode,
+ string systemMaterialMode,
+ string? selectedWallpaperSeed,
+ bool queueWallpaperPaletteBuild)
+ {
+ var availableModes = _windowMaterialService.GetAvailableModes();
+ var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle);
+ var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle);
+ MonetPalette palette;
+ IReadOnlyList wallpaperSeedCandidates;
+ Color effectiveSeedColor;
+ string resolvedSeedSource;
+ string? resolvedWallpaperPath;
+
+ if (string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase))
+ {
+ var wallpaperState = _settingsFacade.Wallpaper.Get();
+ var wallpaperResolution = _wallpaperColorPipeline.Resolve(
+ themeState.IsNightMode,
+ wallpaperState,
+ ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource),
+ selectedWallpaperSeed,
+ queueWallpaperPaletteBuild);
+ palette = wallpaperResolution.Palette;
+ wallpaperSeedCandidates = wallpaperResolution.SeedCandidates;
+ effectiveSeedColor = wallpaperResolution.EffectiveSeedColor;
+ resolvedSeedSource = wallpaperResolution.ResolvedSeedSource;
+ resolvedWallpaperPath = wallpaperResolution.ResolvedWallpaperPath;
+ }
+ else
+ {
+ var preferredSeedColor = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase)
+ ? themeState.ThemeColor
+ : null;
+ palette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, null, preferredSeedColor);
+ wallpaperSeedCandidates = [];
+ effectiveSeedColor = ResolveEffectiveSeedColor(themeColorMode, themeState.ThemeColor, palette);
+ resolvedSeedSource = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)
+ ? "neutral"
+ : "user_color";
+ resolvedWallpaperPath = null;
+ }
+
+ return new AppearanceThemeSnapshot(
+ themeState.IsNightMode,
+ themeColorMode,
+ themeState.ThemeColor,
+ selectedWallpaperSeed,
+ cornerRadiusStyle,
+ cornerRadiusTokens,
+ resolvedSeedSource,
+ palette,
+ ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette),
+ effectiveSeedColor,
+ wallpaperSeedCandidates,
+ systemMaterialMode,
+ availableModes,
+ _windowMaterialService.CanChangeMode,
+ themeState.UseSystemChrome,
+ resolvedWallpaperPath,
+ ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource),
+ themeState.UseNativeWallpaperChangeEvents);
+ }
+
+ private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot)
+ {
+ return new ThemeColorContext(
+ snapshot.AccentColor,
+ IsLightBackground: !snapshot.IsNightMode,
+ IsLightNavBackground: !snapshot.IsNightMode,
+ IsNightMode: snapshot.IsNightMode,
+ MonetPalette: snapshot.MonetPalette,
+ MonetColors: snapshot.MonetPalette.MonetColors,
+ UseNeutralSurfaces: snapshot.ThemeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral,
+ SystemMaterialMode: snapshot.SystemMaterialMode);
+ }
+
+ private string ResolveSupportedMaterialMode(string? requestedMode)
+ {
+ var normalized = ThemeAppearanceValues.NormalizeSystemMaterialMode(requestedMode);
+ var availableModes = _windowMaterialService.GetAvailableModes();
+ return availableModes.Contains(normalized, StringComparer.OrdinalIgnoreCase)
+ ? normalized
+ : ThemeAppearanceValues.MaterialNone;
+ }
+
+ private static Color ResolveAccentColor(
+ string themeColorMode,
+ string? colorText,
+ MonetPalette monetPalette)
+ {
+ if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral)
+ {
+ return DefaultAccentColor;
+ }
+
+ if (monetPalette.Primary.A > 0)
+ {
+ return monetPalette.Primary;
+ }
+
+ if (!string.IsNullOrWhiteSpace(colorText) && Color.TryParse(colorText, out var parsedColor))
+ {
+ return parsedColor;
+ }
+
+ return DefaultAccentColor;
+ }
+
+ private static Color ResolveEffectiveSeedColor(
+ string themeColorMode,
+ string? userThemeColor,
+ MonetPalette monetPalette)
+ {
+ if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral)
+ {
+ return DefaultAccentColor;
+ }
+
+ if (themeColorMode == ThemeAppearanceValues.ColorModeSeedMonet &&
+ !string.IsNullOrWhiteSpace(userThemeColor) &&
+ Color.TryParse(userThemeColor, out var parsedColor))
+ {
+ return parsedColor;
+ }
+
+ return monetPalette.Seed;
+ }
+
+ private void RaiseChanged(bool queueWallpaperPaletteBuild)
+ {
+ var snapshot = BuildCurrentSnapshot(queueWallpaperPaletteBuild);
+ var materialSnapshot = CreateMaterialColorSnapshot(snapshot);
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ AppearanceThemeChanged?.Invoke(this, snapshot);
+ MaterialColorChanged?.Invoke(this, materialSnapshot);
+ return;
+ }
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ AppearanceThemeChanged?.Invoke(this, snapshot);
+ MaterialColorChanged?.Invoke(this, materialSnapshot);
+ }, DispatcherPriority.Background);
+ }
+
+ private MaterialColorSnapshot CreateMaterialColorSnapshot(AppearanceThemeSnapshot snapshot)
+ {
+ var context = CreateThemeContext(snapshot);
+ var appPalette = ThemeColorSystemService.BuildPalette(context);
+ var palette = new LanMountainDesktop.Models.MaterialColorPalette(
+ appPalette.Primary,
+ appPalette.Secondary,
+ appPalette.Accent,
+ appPalette.OnAccent,
+ appPalette.AccentLight1,
+ appPalette.AccentLight2,
+ appPalette.AccentLight3,
+ appPalette.AccentDark1,
+ appPalette.AccentDark2,
+ appPalette.AccentDark3,
+ appPalette.SurfaceBase,
+ appPalette.SurfaceRaised,
+ appPalette.SurfaceOverlay,
+ appPalette.TextPrimary,
+ appPalette.TextSecondary,
+ appPalette.TextMuted,
+ appPalette.TextAccent,
+ appPalette.NavText,
+ appPalette.NavSelectedText,
+ appPalette.NavSelectionIndicator,
+ appPalette.NavItemBackground,
+ appPalette.NavItemHoverBackground,
+ appPalette.NavItemSelectedBackground,
+ appPalette.ToggleOn,
+ appPalette.ToggleOff,
+ appPalette.ToggleBorder);
+ var surfaces = Enum.GetValues()
+ .Select(role =>
+ {
+ var surface = _materialSurfaceService.GetSurface(context, role);
+ return new MaterialSurfaceSnapshot(
+ role,
+ surface.BackgroundColor,
+ surface.BorderColor,
+ surface.BlurRadius,
+ surface.Opacity);
+ })
+ .ToDictionary(surface => surface.Role);
+
+ return new MaterialColorSnapshot(
+ snapshot.IsNightMode,
+ snapshot.ThemeColorMode,
+ snapshot.ThemeWallpaperColorSource,
+ ResolveMaterialColorSourceKind(snapshot),
+ snapshot.ResolvedSeedSource,
+ snapshot.CornerRadiusTokens,
+ snapshot.UserThemeColor,
+ snapshot.SelectedWallpaperSeed,
+ snapshot.EffectiveSeedColor,
+ snapshot.AccentColor,
+ snapshot.MonetPalette,
+ palette,
+ snapshot.WallpaperSeedCandidates,
+ snapshot.SystemMaterialMode,
+ snapshot.AvailableSystemMaterialModes,
+ snapshot.CanChangeSystemMaterial,
+ snapshot.UseSystemChrome,
+ snapshot.ResolvedWallpaperPath,
+ snapshot.UseNativeWallpaperChangeEvents,
+ _nativeWallpaperEventsActive,
+ _wallpaperPollingActive,
+ surfaces);
+ }
+
+ private static MaterialColorSourceKind ResolveMaterialColorSourceKind(AppearanceThemeSnapshot snapshot)
+ {
+ if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase))
+ {
+ return MaterialColorSourceKind.Neutral;
+ }
+
+ if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase))
+ {
+ return MaterialColorSourceKind.CustomSeed;
+ }
+
+ if (!string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase))
+ {
+ return MaterialColorSourceKind.Fallback;
+ }
+
+ if (string.Equals(snapshot.ResolvedSeedSource, "app_wallpaper", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(snapshot.ResolvedSeedSource, "app_solid", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase)
+ ? MaterialColorSourceKind.AppWallpaper
+ : MaterialColorSourceKind.WallpaperAuto;
+ }
+
+ if (string.Equals(snapshot.ResolvedSeedSource, "system_wallpaper", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase)
+ ? MaterialColorSourceKind.SystemWallpaper
+ : MaterialColorSourceKind.WallpaperAuto;
+ }
+
+ return MaterialColorSourceKind.Fallback;
+ }
+
+ private void ConfigureSystemWallpaperMonitoring(ThemeAppearanceSettingsState themeState)
+ {
+ var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(themeState.ThemeColorMode, themeState.ThemeColor);
+ var wallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource);
+ var shouldMonitor =
+ string.Equals(colorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(wallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase);
+
+ if (!shouldMonitor)
+ {
+ StopSystemWallpaperMonitoring();
+ return;
+ }
+
+ ConfigureNativeWallpaperEvents(themeState.UseNativeWallpaperChangeEvents);
+ ConfigureWallpaperPolling(_settingsFacade.Wallpaper.Get().SystemWallpaperRefreshIntervalSeconds);
+ UpdateObservedWallpaperSourceKey();
+ }
+
+ private void ConfigureNativeWallpaperEvents(bool enabled)
+ {
+ if (!enabled || !OperatingSystem.IsWindows())
+ {
+ UnregisterNativeWallpaperEvents();
+ return;
+ }
+
+ if (_nativeWallpaperEventsActive)
+ {
+ return;
+ }
+
+ RegisterNativeWallpaperEvents();
+ }
+
+ private void UnregisterNativeWallpaperEvents()
+ {
+ if (!_nativeWallpaperEventsActive)
+ {
+ return;
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ UnregisterNativeWallpaperEventsCore();
+ }
+
+ _nativeWallpaperEventsActive = false;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void RegisterNativeWallpaperEvents()
+ {
+ try
+ {
+ SystemEvents.UserPreferenceChanged += OnNativeWallpaperPreferenceChanged;
+ _nativeWallpaperEventsActive = true;
+ }
+ catch (Exception ex)
+ {
+ _nativeWallpaperEventsActive = false;
+ AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to subscribe to native wallpaper change events; polling will remain active.", ex);
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void UnregisterNativeWallpaperEventsCore()
+ {
+ try
+ {
+ SystemEvents.UserPreferenceChanged -= OnNativeWallpaperPreferenceChanged;
+ }
+ catch
+ {
+ // Ignore shutdown-time native event cleanup failures.
+ }
+ }
+
+ private void ConfigureWallpaperPolling(int intervalSeconds)
+ {
+ var normalizedInterval = Math.Clamp(intervalSeconds <= 0 ? 300 : intervalSeconds, 30, 86400);
+ var interval = TimeSpan.FromSeconds(normalizedInterval);
+ _systemWallpaperPollTimer ??= new Timer(OnSystemWallpaperPollTimer);
+ _systemWallpaperPollTimer.Change(interval, interval);
+ _wallpaperPollingActive = true;
+ }
+
+ private void StopSystemWallpaperMonitoring()
+ {
+ UnregisterNativeWallpaperEvents();
+ _systemWallpaperPollTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+ _wallpaperPollingActive = false;
+ _lastObservedWallpaperSourceKey = null;
+ }
+
+ private void OnNativeWallpaperPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
+ {
+ _ = sender;
+
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ if (e.Category is UserPreferenceCategory.Desktop or UserPreferenceCategory.General)
+ {
+ RefreshWallpaperColors();
+ }
+ }
+
+ private void OnSystemWallpaperPollTimer(object? state)
+ {
+ _ = state;
+
+ try
+ {
+ var source = _wallpaperColorPipeline.ResolveSource(_settingsFacade.Wallpaper.Get(), _liveThemeWallpaperColorSource);
+ var sourceKey = source.SourceKey;
+ if (string.Equals(_lastObservedWallpaperSourceKey, sourceKey, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ _lastObservedWallpaperSourceKey = sourceKey;
+ RefreshWallpaperColors();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to poll wallpaper color source.", ex);
+ }
+ }
+
+ private void UpdateObservedWallpaperSourceKey()
+ {
+ try
+ {
+ _lastObservedWallpaperSourceKey = _wallpaperColorPipeline.ResolveSource(
+ _settingsFacade.Wallpaper.Get(),
+ _liveThemeWallpaperColorSource).SourceKey;
+ }
+ catch
+ {
+ _lastObservedWallpaperSourceKey = null;
+ }
+ }
+
+
+}
diff --git a/LanMountainDesktop/Services/MaterialSurfaceService.cs b/LanMountainDesktop/Services/MaterialSurfaceService.cs
new file mode 100644
index 0000000..814ad94
--- /dev/null
+++ b/LanMountainDesktop/Services/MaterialSurfaceService.cs
@@ -0,0 +1,144 @@
+using System.Linq;
+using Avalonia.Media;
+using LanMountainDesktop.Services.Settings;
+using LanMountainDesktop.Theme;
+
+namespace LanMountainDesktop.Services;
+
+internal sealed class MaterialSurfaceService : IMaterialSurfaceService
+{
+ public AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role)
+ {
+ var monetPalette = context.MonetPalette;
+ var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
+ var primary = context.UseNeutralSurfaces
+ ? context.AccentColor
+ : monetPalette?.Primary ?? (monetColors.Length > 0 ? monetColors[0] : context.AccentColor);
+ var secondary = monetPalette?.Secondary
+ ?? (monetColors.Length > 1
+ ? monetColors[1]
+ : ColorMath.Blend(primary, Color.Parse("#FFFFFFFF"), 0.14));
+ var neutralPrimary = monetPalette?.Neutral
+ ?? (monetColors.Length > 3
+ ? monetColors[3]
+ : ResolveNeutralBase(context.IsNightMode, role));
+ var neutralSecondary = monetPalette?.NeutralVariant
+ ?? (monetColors.Length > 4
+ ? monetColors[4]
+ : ResolveLiftBase(context.IsNightMode, role));
+ var materialMode = ThemeAppearanceValues.ResolveEffectiveSystemMaterialMode(context.SystemMaterialMode);
+
+ var (tintStrength, liftStrength, alpha, blurRadius) = ResolveModeParameters(materialMode, role, context.IsNightMode);
+ var neutralBase = ResolveNeutralBase(context.IsNightMode, role);
+ var neutralLift = ResolveLiftBase(context.IsNightMode, role);
+ var isDockLike = role is MaterialSurfaceRole.DockBackground;
+ var isComponentLike = role is MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost;
+ var baseMix = isDockLike ? 0.88 : isComponentLike ? 0.74 : 0.82;
+ var liftMix = isDockLike ? 0.58 : isComponentLike ? 0.34 : 0.46;
+ var neutralMix = isDockLike ? 0.22 : 0.16;
+
+ var background = ColorMath.Blend(neutralBase, neutralPrimary, baseMix);
+ background = ColorMath.Blend(background, neutralLift, liftMix);
+ background = ColorMath.Blend(background, neutralSecondary, neutralMix);
+ if (!context.UseNeutralSurfaces)
+ {
+ background = ColorMath.Blend(background, primary, tintStrength);
+ background = ColorMath.Blend(background, secondary, liftStrength);
+ }
+
+ if (isDockLike && !context.IsNightMode)
+ {
+ background = ColorMath.Blend(background, Color.Parse("#FFFFFFFF"), 0.12);
+ }
+
+ background = Color.FromArgb(alpha, background.R, background.G, background.B);
+
+ var borderSeed = context.IsNightMode
+ ? ColorMath.Blend(neutralSecondary, Color.Parse("#FFFFFFFF"), 0.16)
+ : ColorMath.Blend(neutralSecondary, Color.Parse("#FF334155"), 0.08);
+ if (!context.UseNeutralSurfaces && !isComponentLike)
+ {
+ borderSeed = ColorMath.Blend(borderSeed, primary, 0.08);
+ }
+
+ var borderAlpha = role switch
+ {
+ MaterialSurfaceRole.DockBackground => context.IsNightMode ? (byte)0x34 : (byte)0x18,
+ MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost =>
+ context.IsNightMode ? (byte)0x18 : (byte)0x10,
+ MaterialSurfaceRole.StatusBarBackground => (byte)0x00,
+ _ => context.IsNightMode ? (byte)0x26 : (byte)0x16
+ };
+ var border = ColorMath.WithAlpha(borderSeed, borderAlpha);
+
+ return new AppearanceMaterialSurface(background, border, blurRadius, 1.0);
+ }
+
+ private static (double TintStrength, double LiftStrength, byte Alpha, double BlurRadius) ResolveModeParameters(
+ string materialMode,
+ MaterialSurfaceRole role,
+ bool isNightMode)
+ {
+ if (role == MaterialSurfaceRole.SettingsWindowBackground)
+ {
+ return materialMode switch
+ {
+ ThemeAppearanceValues.MaterialAcrylic => (
+ 0.20,
+ 0.14,
+ isNightMode ? (byte)0x8E : (byte)0x96,
+ 0),
+ ThemeAppearanceValues.MaterialMica => (
+ 0.14,
+ 0.08,
+ isNightMode ? (byte)0x9E : (byte)0xA6,
+ 0),
+ _ => (0.08, 0.05, (byte)0xFF, 0)
+ };
+ }
+
+ var isOverlay = role is MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel;
+ return materialMode switch
+ {
+ ThemeAppearanceValues.MaterialAcrylic => (
+ isOverlay ? 0.30 : 0.20,
+ isOverlay ? 0.22 : 0.14,
+ isNightMode ? (byte)0xD8 : (byte)0xE0,
+ isOverlay ? 36 : 28),
+ ThemeAppearanceValues.MaterialMica => (
+ isOverlay ? 0.20 : 0.14,
+ isOverlay ? 0.12 : 0.08,
+ isNightMode ? (byte)0xEC : (byte)0xF2,
+ isOverlay ? 28 : 20),
+ _ => (
+ isOverlay ? 0.12 : 0.08,
+ isOverlay ? 0.08 : 0.05,
+ (byte)0xFF,
+ 0)
+ };
+ }
+
+ private static Color ResolveNeutralBase(bool isNightMode, MaterialSurfaceRole role)
+ {
+ return role switch
+ {
+ MaterialSurfaceRole.WindowBackground => isNightMode ? Color.Parse("#FF0A0F16") : Color.Parse("#FFF7F8FA"),
+ MaterialSurfaceRole.SettingsWindowBackground => isNightMode ? Color.Parse("#FF0C121A") : Color.Parse("#FFF8FAFC"),
+ MaterialSurfaceRole.DockBackground => isNightMode ? Color.Parse("#FF111A24") : Color.Parse("#FFFAFBFD"),
+ MaterialSurfaceRole.StatusBarBackground => isNightMode ? Color.Parse("#FF101720") : Color.Parse("#FFF9FBFE"),
+ MaterialSurfaceRole.StatusBarComponentHost => isNightMode ? Color.Parse("#FF111A23") : Color.Parse("#FFFCFDFE"),
+ MaterialSurfaceRole.OverlayPanel => isNightMode ? Color.Parse("#FF131C27") : Color.Parse("#FFF4F7FB"),
+ _ => isNightMode ? Color.Parse("#FF121B26") : Color.Parse("#FFFDFEFF")
+ };
+ }
+
+ private static Color ResolveLiftBase(bool isNightMode, MaterialSurfaceRole role)
+ {
+ return role switch
+ {
+ MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel =>
+ isNightMode ? Color.Parse("#FF1B2633") : Color.Parse("#FFFFFFFF"),
+ _ => isNightMode ? Color.Parse("#FF17212D") : Color.Parse("#FFFFFFFF")
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/NotificationListenerService.cs b/LanMountainDesktop/Services/NotificationListenerService.cs
index 3eee66a..5fe7c86 100644
--- a/LanMountainDesktop/Services/NotificationListenerService.cs
+++ b/LanMountainDesktop/Services/NotificationListenerService.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
+using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
using LanMountainDesktop.Models;
@@ -9,145 +11,195 @@ using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
+public enum NotificationBoxServiceState
+{
+ NotStarted,
+ Starting,
+ Running,
+ WaitingForPermission,
+ Unsupported,
+ Degraded,
+ Failed
+}
+
+public sealed record NotificationBoxStatus(
+ NotificationBoxServiceState State,
+ string Message,
+ string CaptureMode,
+ bool CanRequestPermission = false);
+
+internal interface IPlatformNotificationListener : IDisposable
+{
+ Task InitializeAsync(CancellationToken cancellationToken = default);
+
+ Task RequestPermissionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+}
+
///
-/// 跨平台通知监听服务
+/// Cross-platform notification aggregation service used by the notification box widget.
///
public sealed class NotificationListenerService : IDisposable
{
private readonly List _notifications = [];
private readonly object _lock = new();
private readonly ISettingsService _settingsService;
-
- // 平台特定的监听器
- private LinuxNotificationListener? _linuxListener;
+ private readonly CancellationTokenSource _disposeCts = new();
+ private IPlatformNotificationListener? _platformListener;
+ private NotificationBoxStatus _status = new(
+ NotificationBoxServiceState.NotStarted,
+ "通知监听尚未启动。",
+ "None");
public event EventHandler? NotificationReceived;
public event EventHandler? NotificationRemoved;
+ public event EventHandler? StatusChanged;
public NotificationListenerService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
- ///
- /// 初始化并启动监听
- ///
public async Task InitializeAsync()
{
+ SetStatus(new NotificationBoxStatus(NotificationBoxServiceState.Starting, "正在启动通知监听...", "Starting"));
+
try
{
+ var settings = _settingsService.LoadSnapshot(SettingsScope.App);
+ if (!settings.NotificationBoxEnabled)
+ {
+ SetStatus(new NotificationBoxStatus(NotificationBoxServiceState.Unsupported, "消息盒子已在设置中关闭。", "Disabled"));
+ return;
+ }
+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- // Windows: 使用 UserNotificationListener (需要Windows SDK)
- // 当前为模拟实现
- await InitializeWindowsAsync();
+ _platformListener = new WindowsNotificationListener(this);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
- // Linux: 使用 DBus
- await InitializeLinuxAsync();
+ _platformListener = new LinuxNotificationListener(this, settings.NotificationBoxLinuxCaptureMode);
}
else
{
- // macOS 或其他平台:功能不可用
- Console.WriteLine("[NotificationBox] 当前平台不支持通知监听");
+ SetStatus(new NotificationBoxStatus(
+ NotificationBoxServiceState.Unsupported,
+ "当前平台暂不支持系统通知监听。",
+ "Unsupported"));
+ return;
}
+
+ var status = await _platformListener.InitializeAsync(_disposeCts.Token).ConfigureAwait(false);
+ SetStatus(status);
}
catch (Exception ex)
{
- Console.WriteLine($"[NotificationBox] 初始化失败: {ex.Message}");
+ SetStatus(new NotificationBoxStatus(
+ NotificationBoxServiceState.Failed,
+ $"通知监听初始化失败:{ex.Message}",
+ "Failed"));
}
}
- private async Task InitializeWindowsAsync()
- {
- // Windows通知监听实现
- // 实际项目中需要添加Windows SDK引用并使用UserNotificationListener
- // 由于需要UWP API,这里使用模拟实现
- await Task.CompletedTask;
- Console.WriteLine("[NotificationBox] Windows通知监听已启动(模拟模式)");
- }
+ public NotificationBoxStatus GetStatus() => _status;
- private async Task InitializeLinuxAsync()
+ public async Task RequestPermissionAsync(CancellationToken cancellationToken = default)
{
+ if (_platformListener is null)
+ {
+ await InitializeAsync().ConfigureAwait(false);
+ return;
+ }
+
try
{
- _linuxListener = new LinuxNotificationListener(this);
- var success = await _linuxListener.InitializeAsync();
-
- if (!success)
- {
- Console.WriteLine("[NotificationBox] Linux通知监听初始化失败,可能未运行通知守护进程");
- }
+ await _platformListener.RequestPermissionAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
- Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
+ SetStatus(new NotificationBoxStatus(
+ NotificationBoxServiceState.Failed,
+ $"请求通知权限失败:{ex.Message}",
+ _status.CaptureMode,
+ CanRequestPermission: true));
}
}
- ///
- /// 添加通知(供平台监听器调用)
- ///
+ public void SetStatus(NotificationBoxStatus status)
+ {
+ _status = status;
+ Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, status));
+ }
+
public void AddNotification(NotificationItem notification)
{
var settings = _settingsService.LoadSnapshot(SettingsScope.App);
-
- // 检查全局开关
if (!settings.NotificationBoxEnabled)
- return;
-
- // 检查是否在屏蔽列表中
- if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase))
- return;
-
- lock (_lock)
{
- _notifications.Add(notification);
- CleanupOldNotifications(settings);
+ return;
}
- // 在UI线程触发事件
- Dispatcher.UIThread.InvokeAsync(() =>
+ if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase) ||
+ settings.NotificationBoxBlockedApps.Contains(notification.AppName, StringComparer.OrdinalIgnoreCase))
{
- NotificationReceived?.Invoke(this, notification);
- });
- }
+ return;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ if (notification.ReceivedAtUtc == default)
+ {
+ notification.ReceivedAtUtc = now;
+ }
+
+ if (notification.ReceivedTime == default)
+ {
+ notification.ReceivedTime = notification.ReceivedAtUtc.LocalDateTime;
+ }
- ///
- /// 移除通知
- ///
- public void RemoveNotification(string notificationId)
- {
lock (_lock)
{
- var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
- if (notification != null)
+ var existing = !string.IsNullOrWhiteSpace(notification.SourceNotificationId)
+ ? _notifications.FirstOrDefault(n =>
+ string.Equals(n.Platform, notification.Platform, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(n.SourceNotificationId, notification.SourceNotificationId, StringComparison.OrdinalIgnoreCase))
+ : null;
+
+ if (existing is not null)
{
- _notifications.Remove(notification);
+ CopyNotification(notification, existing);
+ CleanupOldNotifications(settings);
+ }
+ else
+ {
+ _notifications.Add(notification);
+ CleanupOldNotifications(settings);
}
}
- NotificationRemoved?.Invoke(this, notificationId);
+ Dispatcher.UIThread.InvokeAsync(() => NotificationReceived?.Invoke(this, notification));
}
- private void CleanupOldNotifications(AppSettingsSnapshot settings)
+ public void RemoveNotification(string notificationId)
{
- // 按数量清理
- var maxCount = settings.NotificationBoxMaxStoredCount;
- while (_notifications.Count > maxCount)
+ var removed = false;
+ lock (_lock)
{
- _notifications.RemoveAt(0);
+ var notification = _notifications.FirstOrDefault(n =>
+ string.Equals(n.Id, notificationId, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(n.SourceNotificationId, notificationId, StringComparison.OrdinalIgnoreCase));
+ if (notification != null)
+ {
+ _notifications.Remove(notification);
+ removed = true;
+ }
}
- // 按时间清理
- var cutoffDate = DateTime.Now.AddDays(-settings.NotificationBoxHistoryRetentionDays);
- _notifications.RemoveAll(n => n.ReceivedTime < cutoffDate);
+ if (removed)
+ {
+ Dispatcher.UIThread.InvokeAsync(() => NotificationRemoved?.Invoke(this, notificationId));
+ }
}
- ///
- /// 获取所有通知
- ///
public IReadOnlyList GetNotifications()
{
lock (_lock)
@@ -156,20 +208,16 @@ public sealed class NotificationListenerService : IDisposable
}
}
- ///
- /// 清空所有通知
- ///
public void ClearAll()
{
lock (_lock)
{
_notifications.Clear();
}
+
+ Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, _status));
}
- ///
- /// 标记通知为已读
- ///
public void MarkAsRead(string notificationId)
{
lock (_lock)
@@ -182,9 +230,6 @@ public sealed class NotificationListenerService : IDisposable
}
}
- ///
- /// 获取未读通知数量
- ///
public int GetUnreadCount()
{
lock (_lock)
@@ -193,9 +238,187 @@ public sealed class NotificationListenerService : IDisposable
}
}
+ public bool TryActivate(NotificationItem notification)
+ {
+ if (!notification.CanActivate)
+ {
+ return false;
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ return TryLaunchWindows(notification);
+ }
+
+ if (OperatingSystem.IsLinux())
+ {
+ return TryLaunchLinux(notification);
+ }
+
+ return false;
+ }
+
+ private static bool TryLaunchWindows(NotificationItem notification)
+ {
+ try
+ {
+ var target = notification.LaunchTarget;
+ if (string.IsNullOrWhiteSpace(target) && !string.IsNullOrWhiteSpace(notification.Aumid))
+ {
+ target = $"shell:AppsFolder\\{notification.Aumid}";
+ }
+
+ if (string.IsNullOrWhiteSpace(target))
+ {
+ return false;
+ }
+
+ if (target.StartsWith("shell:AppsFolder\\", StringComparison.OrdinalIgnoreCase))
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "explorer.exe",
+ Arguments = target,
+ UseShellExecute = true
+ });
+ }
+ else
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = target,
+ UseShellExecute = true
+ });
+ }
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryLaunchLinux(NotificationItem notification)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(notification.DesktopEntryId))
+ {
+ var root = new LinuxDesktopEntryService().Load();
+ var entry = EnumerateApps(root).FirstOrDefault(app =>
+ string.Equals(app.RelativePath, notification.DesktopEntryId, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(app.RelativePath, $"{notification.DesktopEntryId}.desktop", StringComparison.OrdinalIgnoreCase));
+ if (entry is not null && !string.IsNullOrWhiteSpace(entry.LaunchExecutable))
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = entry.LaunchExecutable,
+ UseShellExecute = false
+ };
+ foreach (var argument in entry.LaunchArguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+ if (!string.IsNullOrWhiteSpace(entry.WorkingDirectory))
+ {
+ startInfo.WorkingDirectory = entry.WorkingDirectory;
+ }
+ Process.Start(startInfo);
+ return true;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(notification.LaunchTarget))
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = notification.LaunchTarget,
+ UseShellExecute = true
+ });
+ return true;
+ }
+ }
+ catch
+ {
+ }
+
+ return false;
+ }
+
+ private void CleanupOldNotifications(AppSettingsSnapshot settings)
+ {
+ var maxCount = Math.Max(1, settings.NotificationBoxMaxStoredCount);
+ while (_notifications.Count > maxCount)
+ {
+ _notifications.RemoveAt(0);
+ }
+
+ var cutoffDate = DateTime.Now.AddDays(-Math.Max(1, settings.NotificationBoxHistoryRetentionDays));
+ _notifications.RemoveAll(n => n.ReceivedTime < cutoffDate);
+ }
+
+ private static IEnumerable EnumerateApps(StartMenuFolderNode node)
+ {
+ foreach (var app in node.Apps)
+ {
+ yield return app;
+ }
+
+ foreach (var folder in node.Folders)
+ {
+ foreach (var app in EnumerateApps(folder))
+ {
+ yield return app;
+ }
+ }
+ }
+
+ private static void CopyNotification(NotificationItem source, NotificationItem target)
+ {
+ target.AppId = source.AppId;
+ target.AppName = source.AppName;
+ target.AppIconPath = source.AppIconPath;
+ target.AppIconBytes = source.AppIconBytes;
+ target.Title = source.Title;
+ target.Content = source.Content;
+ target.ReceivedTime = source.ReceivedTime;
+ target.ReceivedAtUtc = source.ReceivedAtUtc;
+ target.LaunchArgs = source.LaunchArgs;
+ target.Platform = source.Platform;
+ target.SourceNotificationId = source.SourceNotificationId;
+ target.DesktopEntryId = source.DesktopEntryId;
+ target.Aumid = source.Aumid;
+ target.LaunchTarget = source.LaunchTarget;
+ target.CanActivate = source.CanActivate;
+ target.CaptureMode = source.CaptureMode;
+ }
+
public void Dispose()
{
- _linuxListener?.Dispose();
+ _disposeCts.Cancel();
+ _platformListener?.Dispose();
+ _disposeCts.Dispose();
ClearAll();
}
}
+
+public static class NotificationListenerServiceProvider
+{
+ private static readonly object Gate = new();
+ private static NotificationListenerService? _instance;
+
+ public static NotificationListenerService GetOrCreate(ISettingsService settingsService)
+ {
+ lock (Gate)
+ {
+ if (_instance == null)
+ {
+ _instance = new NotificationListenerService(settingsService);
+ _ = _instance.InitializeAsync();
+ }
+
+ return _instance;
+ }
+ }
+}
diff --git a/LanMountainDesktop/Services/NotificationService.cs b/LanMountainDesktop/Services/NotificationService.cs
index 500c861..1fdb324 100644
--- a/LanMountainDesktop/Services/NotificationService.cs
+++ b/LanMountainDesktop/Services/NotificationService.cs
@@ -94,13 +94,18 @@ public interface INotificationService
internal sealed class NotificationService : INotificationService
{
- private readonly IAppearanceThemeService? _appearanceThemeService;
+ private readonly IMaterialColorService _materialColorService;
private readonly NotificationWindowManager _windowManager;
- public NotificationService(IAppearanceThemeService? appearanceThemeService = null)
+ public NotificationService(
+ IAppearanceThemeService? appearanceThemeService = null,
+ IMaterialColorService? materialColorService = null)
{
- _appearanceThemeService = appearanceThemeService;
+ _materialColorService = materialColorService
+ ?? appearanceThemeService as IMaterialColorService
+ ?? HostMaterialColorProvider.GetOrCreate();
_windowManager = NotificationWindowManager.Instance;
+ _materialColorService.MaterialColorChanged += OnMaterialColorChanged;
}
public void Show(NotificationContent content)
@@ -122,7 +127,7 @@ internal sealed class NotificationService : INotificationService
private void ShowDialogWindow(NotificationContent content)
{
var window = new NotificationDialogWindow();
- window.Initialize(content, _appearanceThemeService);
+ window.Initialize(content, _materialColorService.GetMaterialColorSnapshot());
Screen? screen = null;
if (Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
@@ -223,6 +228,7 @@ internal sealed class NotificationService : INotificationService
private void ShowCore(NotificationContent content)
{
+ var materialColorSnapshot = _materialColorService.GetMaterialColorSnapshot();
var viewModel = new NotificationViewModel
{
Title = content.Title,
@@ -253,7 +259,13 @@ internal sealed class NotificationService : INotificationService
}
}
- _windowManager.ShowNotification(viewModel, _appearanceThemeService);
+ _windowManager.ShowNotification(viewModel, materialColorSnapshot);
+ }
+
+ private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot)
+ {
+ _ = sender;
+ _windowManager.ApplyMaterialColorToAllWindows(snapshot);
}
public void ShowInfo(string title, string? message = null,
@@ -346,7 +358,7 @@ internal sealed class NotificationWindowManager
}
}
- public void ShowNotification(NotificationViewModel viewModel, IAppearanceThemeService? themeService)
+ public void ShowNotification(NotificationViewModel viewModel, MaterialColorSnapshot materialColorSnapshot)
{
var position = viewModel.Position;
var windows = _windowsByPosition[position];
@@ -362,7 +374,7 @@ internal sealed class NotificationWindowManager
}
var window = new NotificationWindow();
- window.Initialize(viewModel, themeService);
+ window.Initialize(viewModel, materialColorSnapshot);
window.Closed += OnWindowClosed;
windows.Add(window);
@@ -371,6 +383,23 @@ internal sealed class NotificationWindowManager
window.ShowWithAnimationAsync();
}
+ public void ApplyMaterialColorToAllWindows(MaterialColorSnapshot snapshot)
+ {
+ foreach (var windows in _windowsByPosition.Values)
+ {
+ foreach (var window in windows.ToList())
+ {
+ try
+ {
+ window.ApplyMaterialSnapshot(snapshot);
+ }
+ catch
+ {
+ }
+ }
+ }
+ }
+
private void OnWindowClosed(object? sender, EventArgs e)
{
if (sender is not NotificationWindow window) return;
@@ -484,20 +513,4 @@ internal sealed class NotificationWindowManager
return null;
}
- public void ApplyThemeToAllWindows(AppearanceThemeSnapshot snapshot)
- {
- foreach (var windows in _windowsByPosition.Values)
- {
- foreach (var window in windows.ToList())
- {
- try
- {
- window.RequestedThemeVariant = snapshot.IsNightMode ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light;
- }
- catch
- {
- }
- }
- }
- }
}
diff --git a/LanMountainDesktop/Services/PlondsStaticUpdateService.cs b/LanMountainDesktop/Services/PlondsStaticUpdateService.cs
new file mode 100644
index 0000000..9af5ce9
--- /dev/null
+++ b/LanMountainDesktop/Services/PlondsStaticUpdateService.cs
@@ -0,0 +1,278 @@
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace LanMountainDesktop.Services;
+
+internal sealed class PlondsStaticUpdateService : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private readonly bool _ownsHttpClient;
+ private readonly string _baseUrl;
+
+ public PlondsStaticUpdateService(string? baseUrl = null, HttpClient? httpClient = null)
+ {
+ _baseUrl = NormalizeBaseUrl(baseUrl ?? ResolveConfiguredBaseUrl());
+ if (httpClient is null)
+ {
+ _httpClient = new HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(30)
+ };
+ _ownsHttpClient = true;
+ }
+ else
+ {
+ _httpClient = httpClient;
+ _ownsHttpClient = false;
+ }
+
+ if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
+ {
+ _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
+ }
+ }
+
+ public Task CheckForUpdatesAsync(
+ Version currentVersion,
+ bool includePrerelease,
+ CancellationToken cancellationToken = default)
+ {
+ return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
+ }
+
+ public Task ForceCheckForUpdatesAsync(
+ Version currentVersion,
+ bool includePrerelease,
+ CancellationToken cancellationToken = default)
+ {
+ return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
+ }
+
+ public void Dispose()
+ {
+ if (_ownsHttpClient)
+ {
+ _httpClient.Dispose();
+ }
+ }
+
+ internal static string ResolveCurrentPlatform()
+ {
+ var os = OperatingSystem.IsWindows()
+ ? "windows"
+ : OperatingSystem.IsLinux()
+ ? "linux"
+ : OperatingSystem.IsMacOS()
+ ? "macos"
+ : "unknown";
+
+ var arch = RuntimeInformation.OSArchitecture switch
+ {
+ Architecture.X86 => "x86",
+ Architecture.Arm => "arm",
+ Architecture.Arm64 => "arm64",
+ _ => "x64"
+ };
+
+ return $"{os}-{arch}";
+ }
+
+ private async Task CheckForUpdatesCoreAsync(
+ Version currentVersion,
+ bool includePrerelease,
+ bool isForce,
+ CancellationToken cancellationToken)
+ {
+ var currentVersionText = FormatVersion(currentVersion);
+ var channel = includePrerelease ? UpdateSettingsValues.ChannelPreview : UpdateSettingsValues.ChannelStable;
+ var platform = ResolveCurrentPlatform();
+
+ try
+ {
+ var latestUrl = BuildUrl($"meta/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest.json");
+ var latest = await GetJsonAsync(latestUrl, cancellationToken);
+ if (latest is null || string.IsNullOrWhiteSpace(latest.DistributionId))
+ {
+ return Failed(currentVersionText, isForce, $"PLONDS static latest manifest is unavailable at {latestUrl}.");
+ }
+
+ var distributionUrl = BuildUrl($"meta/distributions/{Uri.EscapeDataString(latest.DistributionId)}.json");
+ var distribution = await GetJsonAsync(distributionUrl, cancellationToken);
+ if (distribution is null)
+ {
+ return Failed(currentVersionText, isForce, $"PLONDS static distribution manifest is unavailable at {distributionUrl}.");
+ }
+
+ var latestVersionText = FirstNonEmpty(distribution.Version, latest.Version) ?? "-";
+ var isNewer = TryParseVersion(latestVersionText, out var latestVersion) && latestVersion > currentVersion;
+ var isUpdateAvailable = isForce || isNewer;
+ var payload = isUpdateAvailable
+ ? CreatePayload(distribution, latest, channel, platform)
+ : null;
+
+ return new UpdateCheckResult(
+ Success: true,
+ IsUpdateAvailable: isUpdateAvailable,
+ CurrentVersionText: currentVersionText,
+ LatestVersionText: latestVersionText,
+ Release: null,
+ PreferredAsset: null,
+ ErrorMessage: null,
+ ForceMode: isForce,
+ PlondsPayload: payload);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return Failed(currentVersionText, isForce, ex.Message);
+ }
+ }
+
+ private PlondsUpdatePayload CreatePayload(
+ DistributionDto distribution,
+ LatestPointerDto latest,
+ string channel,
+ string platform)
+ {
+ var distributionId = FirstNonEmpty(distribution.DistributionId, latest.DistributionId) ?? string.Empty;
+ var fileMapUrl = FirstNonEmpty(distribution.FileMapUrl, BuildUrl($"manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json"));
+ var signatureUrl = FirstNonEmpty(distribution.FileMapSignatureUrl, fileMapUrl + ".sig");
+
+ return new PlondsUpdatePayload(
+ DistributionId: distributionId,
+ ChannelId: FirstNonEmpty(distribution.Channel, latest.Channel, channel) ?? channel,
+ SubChannel: FirstNonEmpty(distribution.Platform, latest.Platform, platform) ?? platform,
+ FileMapJson: null,
+ FileMapSignature: null,
+ FileMapJsonUrl: fileMapUrl,
+ FileMapSignatureUrl: signatureUrl);
+ }
+
+ private async Task GetJsonAsync(string url, CancellationToken cancellationToken)
+ {
+ using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ return default;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ throw new InvalidOperationException($"HTTP {(int)response.StatusCode} from {url}: {Truncate(body, 256)}");
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken);
+ }
+
+ private static UpdateCheckResult Failed(string currentVersionText, bool isForce, string message)
+ {
+ return new UpdateCheckResult(
+ Success: false,
+ IsUpdateAvailable: false,
+ CurrentVersionText: currentVersionText,
+ LatestVersionText: "-",
+ Release: null,
+ PreferredAsset: null,
+ ErrorMessage: message,
+ ForceMode: isForce);
+ }
+
+ private string BuildUrl(string relativePath)
+ {
+ return $"{_baseUrl}/{relativePath.TrimStart('/')}";
+ }
+
+ private static string ResolveConfiguredBaseUrl()
+ {
+ var environmentValue = Environment.GetEnvironmentVariable(UpdateSettingsValues.PlondsStaticBaseUrlEnvironmentVariable);
+ return string.IsNullOrWhiteSpace(environmentValue)
+ ? UpdateSettingsValues.DefaultPlondsStaticBaseUrl
+ : environmentValue;
+ }
+
+ private static string NormalizeBaseUrl(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return UpdateSettingsValues.DefaultPlondsStaticBaseUrl;
+ }
+
+ return value.Trim().TrimEnd('/');
+ }
+
+ private static bool TryParseVersion(string? value, out Version version)
+ {
+ version = new Version(0, 0, 0);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed))
+ {
+ return false;
+ }
+
+ version = parsed;
+ return true;
+ }
+
+ private static string FormatVersion(Version version)
+ {
+ if (version.Revision >= 0)
+ {
+ return version.ToString();
+ }
+
+ return version.Build >= 0
+ ? $"{version.Major}.{version.Minor}.{version.Build}"
+ : $"{version.Major}.{version.Minor}";
+ }
+
+ private static string? FirstNonEmpty(params string?[] values)
+ {
+ return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
+ }
+
+ private static string Truncate(string value, int maxLength)
+ {
+ if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
+ {
+ return value;
+ }
+
+ return value[..maxLength];
+ }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true
+ };
+
+ private sealed record LatestPointerDto(
+ string? DistributionId,
+ string? Version,
+ string? Channel,
+ string? Platform,
+ DateTimeOffset PublishedAt);
+
+ private sealed record DistributionDto(
+ string? DistributionId,
+ string? Version,
+ string? SourceVersion,
+ string? Channel,
+ string? Platform,
+ DateTimeOffset PublishedAt,
+ string? FileMapUrl,
+ string? FileMapSignatureUrl);
+}
diff --git a/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs b/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs
new file mode 100644
index 0000000..3936c01
--- /dev/null
+++ b/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Media;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+
+namespace LanMountainDesktop.Services;
+
+internal static class PluginAppearanceSnapshotMapper
+{
+ ///
+ /// Normal host-to-plugin appearance mapping for the live material color pipeline.
+ ///
+ public static PluginAppearanceSnapshot FromMaterialColorSnapshot(MaterialColorSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ return new PluginAppearanceSnapshot(
+ PluginCornerRadiusTokens.FromShared(snapshot.CornerRadiusTokens),
+ snapshot.IsNightMode ? "Dark" : "Light",
+ ToText(snapshot.AccentColor),
+ ToText(snapshot.EffectiveSeedColor),
+ snapshot.ColorSourceKind.ToString(),
+ snapshot.SystemMaterialMode,
+ BuildColorRoles(snapshot),
+ snapshot.Surfaces.ToDictionary(
+ pair => pair.Key.ToString(),
+ pair => new PluginMaterialSurfaceSnapshot(
+ ToText(pair.Value.BackgroundColor),
+ ToText(pair.Value.BorderColor),
+ pair.Value.BlurRadius,
+ pair.Value.Opacity),
+ StringComparer.OrdinalIgnoreCase),
+ snapshot.WallpaperSeedCandidates.Select(ToText).ToArray());
+ }
+
+ ///
+ /// Compatibility-only mapper for older hosts that still expose
+ /// instead of the material color pipeline.
+ ///
+ public static PluginAppearanceSnapshot FromCompatibilityAppearanceSnapshot(AppearanceThemeSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ return new PluginAppearanceSnapshot(
+ PluginCornerRadiusTokens.FromShared(snapshot.CornerRadiusTokens),
+ snapshot.IsNightMode ? "Dark" : "Light",
+ ToText(snapshot.AccentColor),
+ ToText(snapshot.EffectiveSeedColor),
+ snapshot.ResolvedSeedSource,
+ snapshot.SystemMaterialMode,
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["primary"] = ToText(snapshot.MonetPalette.Primary),
+ ["secondary"] = ToText(snapshot.MonetPalette.Secondary),
+ ["tertiary"] = ToText(snapshot.MonetPalette.Tertiary),
+ ["neutral"] = ToText(snapshot.MonetPalette.Neutral),
+ ["neutralVariant"] = ToText(snapshot.MonetPalette.NeutralVariant),
+ ["accent"] = ToText(snapshot.AccentColor)
+ },
+ null,
+ snapshot.WallpaperSeedCandidates.Select(ToText).ToArray());
+ }
+
+ ///
+ /// Backward-compatible alias for older call sites. Prefer .
+ ///
+ [Obsolete("Use FromCompatibilityAppearanceSnapshot instead.")]
+ public static PluginAppearanceSnapshot FromAppearanceSnapshot(AppearanceThemeSnapshot snapshot)
+ {
+ return FromCompatibilityAppearanceSnapshot(snapshot);
+ }
+
+ private static IReadOnlyDictionary BuildColorRoles(MaterialColorSnapshot snapshot)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["primary"] = ToText(snapshot.Palette.Primary),
+ ["secondary"] = ToText(snapshot.Palette.Secondary),
+ ["accent"] = ToText(snapshot.Palette.Accent),
+ ["onAccent"] = ToText(snapshot.Palette.OnAccent),
+ ["surfaceBase"] = ToText(snapshot.Palette.SurfaceBase),
+ ["surfaceRaised"] = ToText(snapshot.Palette.SurfaceRaised),
+ ["surfaceOverlay"] = ToText(snapshot.Palette.SurfaceOverlay),
+ ["textPrimary"] = ToText(snapshot.Palette.TextPrimary),
+ ["textSecondary"] = ToText(snapshot.Palette.TextSecondary),
+ ["textMuted"] = ToText(snapshot.Palette.TextMuted),
+ ["textAccent"] = ToText(snapshot.Palette.TextAccent),
+ ["toggleOn"] = ToText(snapshot.Palette.ToggleOn),
+ ["toggleOff"] = ToText(snapshot.Palette.ToggleOff),
+ ["toggleBorder"] = ToText(snapshot.Palette.ToggleBorder)
+ };
+ }
+
+ private static string ToText(Color color)
+ {
+ return color.ToString();
+ }
+}
diff --git a/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs b/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs
index 2e2dada..3d0e289 100644
--- a/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs
@@ -16,7 +16,9 @@ internal sealed class SettingsCatalogService : ISettingsCatalog
_sections.AddRange(
[
new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0),
+ new SettingsSectionDefinition("material-color", SettingsCategories.Appearance, SettingsScope.App, "settings.material_color.title", iconKey: "Color", sortOrder: 8),
new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10),
+ new SettingsSectionDefinition("wallpaper", SettingsCategories.Appearance, SettingsScope.App, "settings.wallpaper.title", iconKey: "Image", sortOrder: 15),
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "Apps", sortOrder: 20),
new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30),
new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 40)
diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs
index 67479d6..7e97752 100644
--- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs
@@ -20,11 +20,10 @@ public enum WallpaperMediaType
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(
- string? WallpaperPath,
- string Type,
- string? Color,
- string Placement,
- string? CustomColor = null,
+ string? WallpaperPath,
+ string Type,
+ string? Color,
+ string Placement,
int SystemWallpaperRefreshIntervalSeconds = 300);
public sealed record ThemeAppearanceSettingsState(
bool IsNightMode,
@@ -34,7 +33,9 @@ public sealed record ThemeAppearanceSettingsState(
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
string? SelectedWallpaperSeed = null,
- string ThemeMode = ThemeAppearanceValues.ThemeModeLight);
+ string ThemeMode = ThemeAppearanceValues.ThemeModeLight,
+ string ThemeWallpaperColorSource = ThemeAppearanceValues.WallpaperColorSourceAuto,
+ bool UseNativeWallpaperChangeEvents = true);
public sealed record StatusBarSettingsState(
IReadOnlyList TopStatusComponentIds,
IReadOnlyList PinnedTaskbarActions,
@@ -88,6 +89,7 @@ public sealed record UpdateSettingsState(
string UpdateMode,
string UpdateDownloadSource,
int UpdateDownloadThreads,
+ bool ForceUpdateReinstall,
bool UseGhProxyMirror,
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index 4576e52..e35d9b1 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -102,7 +102,6 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService
normalizedType,
snapshot.WallpaperColor,
snapshot.WallpaperPlacement,
- CustomColor: null,
SystemWallpaperRefreshIntervalSeconds: NormalizeRefreshInterval(snapshot.SystemWallpaperRefreshIntervalSeconds));
}
@@ -267,7 +266,9 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
snapshot.SelectedWallpaperSeed,
- NormalizeThemeMode(snapshot.ThemeMode));
+ NormalizeThemeMode(snapshot.ThemeMode),
+ ThemeAppearanceValues.NormalizeWallpaperColorSource(snapshot.ThemeWallpaperColorSource),
+ snapshot.UseNativeWallpaperChangeEvents);
}
private static string NormalizeThemeMode(string? value)
@@ -294,6 +295,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
? null
: state.SelectedWallpaperSeed;
+ var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(state.ThemeWallpaperColorSource);
if ((snapshot.IsNightMode ?? false) != state.IsNightMode)
{
@@ -337,6 +339,18 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed));
}
+ if (!string.Equals(snapshot.ThemeWallpaperColorSource, normalizedWallpaperColorSource, StringComparison.OrdinalIgnoreCase))
+ {
+ snapshot.ThemeWallpaperColorSource = normalizedWallpaperColorSource;
+ changedKeys.Add(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource));
+ }
+
+ if (snapshot.UseNativeWallpaperChangeEvents != state.UseNativeWallpaperChangeEvents)
+ {
+ snapshot.UseNativeWallpaperChangeEvents = state.UseNativeWallpaperChangeEvents;
+ changedKeys.Add(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents));
+ }
+
var normalizedThemeMode = NormalizeThemeMode(state.ThemeMode);
if (!string.Equals(snapshot.ThemeMode, normalizedThemeMode, StringComparison.OrdinalIgnoreCase))
{
@@ -654,9 +668,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
private static string NormalizeIconPackId(string? iconPackId)
{
- return string.IsNullOrWhiteSpace(iconPackId)
- ? "HyperOS3"
- : "HyperOS3";
+ return WeatherVisualStyleCatalog.Normalize(iconPackId);
}
}
@@ -770,6 +782,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
+ private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new();
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
@@ -789,6 +802,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource),
UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads),
+ snapshot.ForceUpdateReinstall,
snapshot.UseGhProxyMirror,
snapshot.PendingUpdateInstallerPath,
snapshot.PendingUpdateVersion,
@@ -811,6 +825,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource);
snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
+ snapshot.ForceUpdateReinstall = state.ForceUpdateReinstall;
snapshot.UseGhProxyMirror = state.UseGhProxyMirror;
snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath)
? null
@@ -832,12 +847,12 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot,
changedKeys:
[
- nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.UpdateChannel),
nameof(AppSettingsSnapshot.UpdateMode),
nameof(AppSettingsSnapshot.UpdateDownloadSource),
nameof(AppSettingsSnapshot.UpdateDownloadThreads),
+ nameof(AppSettingsSnapshot.ForceUpdateReinstall),
nameof(AppSettingsSnapshot.UseGhProxyMirror),
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
nameof(AppSettingsSnapshot.PendingUpdateVersion),
@@ -869,6 +884,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool isForce = false,
CancellationToken cancellationToken = default)
{
+ var staticResult = isForce
+ ? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
+ : await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
+ if (staticResult.Success && staticResult.PlondsPayload is not null)
+ {
+ return staticResult.PlondsPayload;
+ }
+
var result = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
@@ -912,6 +935,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
+ _plondsStaticUpdateService.Dispose();
_plondsReleaseUpdateService.Dispose();
}
@@ -921,6 +945,19 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool isForce,
CancellationToken cancellationToken)
{
+ var staticResult = isForce
+ ? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
+ : await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
+
+ if (staticResult.Success)
+ {
+ return staticResult;
+ }
+
+ AppLogger.Warn(
+ "UpdateSettings",
+ $"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}");
+
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
diff --git a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
index 4056516..4abff3b 100644
--- a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
@@ -179,6 +179,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
services.AddSingleton(_settingsFacade.Settings);
services.AddSingleton(_settingsFacade.Catalog);
services.AddSingleton(_ => HostAppearanceThemeProvider.GetOrCreate());
+ services.AddSingleton(_ => HostMaterialColorProvider.GetOrCreate());
services.AddSingleton(_hostApplicationLifecycle);
services.AddSingleton(_localizationService);
services.AddSingleton(_ => HostLocationServiceProvider.GetOrCreate());
diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs
index edc0ad1..f18d9cb 100644
--- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs
@@ -71,7 +71,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_window ??= CreateWindow();
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
_window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
- ApplyTheme(_window);
+ ApplyThemeVariantAndResources(_window);
var targetPageId = request.PageId ?? _window.ViewModel.CurrentPageId;
_window.ReloadPages(targetPageId);
@@ -79,6 +79,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
if (!_window.IsVisible)
{
CenterWindow(_window, request);
+ _appearanceThemeService.ApplyWindowMaterial(_window, MaterialSurfaceRole.SettingsWindowBackground);
_window.Show();
NotifyStateChanged();
CenterWindowLater(_window, request);
@@ -90,6 +91,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_window.WindowState = WindowState.Normal;
}
+ _appearanceThemeService.ApplyWindowMaterial(_window, MaterialSurfaceRole.SettingsWindowBackground);
_window.Activate();
}
@@ -113,7 +115,6 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_pageRegistry,
_hostApplicationLifecycle,
useSystemChrome);
- ApplyTheme(window);
window.ShowInTaskbar = true;
window.Closed += (_, _) =>
{
@@ -233,12 +234,18 @@ internal sealed class SettingsWindowService : ISettingsWindowService
var themeChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase))) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase);
if (languageChanged || devModeChanged)
@@ -285,13 +292,23 @@ internal sealed class SettingsWindowService : ISettingsWindowService
}, DispatcherPriority.Background);
}
- private void ApplyTheme(SettingsWindow window)
+ private static void ApplyThemeVariantAndResources(SettingsWindow window, IAppearanceThemeService appearanceThemeService)
{
- var appearanceSnapshot = _appearanceThemeService.GetCurrent();
+ var appearanceSnapshot = appearanceThemeService.GetCurrent();
window.RequestedThemeVariant = appearanceSnapshot.IsNightMode
? ThemeVariant.Dark
: ThemeVariant.Light;
- _appearanceThemeService.ApplyThemeResources(window.Resources);
+ appearanceThemeService.ApplyThemeResources(window.Resources);
+ }
+
+ private void ApplyThemeVariantAndResources(SettingsWindow window)
+ {
+ ApplyThemeVariantAndResources(window, _appearanceThemeService);
+ }
+
+ private void ApplyTheme(SettingsWindow window)
+ {
+ ApplyThemeVariantAndResources(window, _appearanceThemeService);
_appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.SettingsWindowBackground);
}
diff --git a/LanMountainDesktop/Services/SettingsSearchService.cs b/LanMountainDesktop/Services/SettingsSearchService.cs
new file mode 100644
index 0000000..4c21a7f
--- /dev/null
+++ b/LanMountainDesktop/Services/SettingsSearchService.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.VisualTree;
+using FluentAvalonia.UI.Controls;
+using LanMountainDesktop.Services.Settings;
+
+namespace LanMountainDesktop.Services;
+
+public sealed class SettingsSearchResult
+{
+ public SettingsSearchResult(
+ string pageId,
+ string pageTitle,
+ string? pageDescription,
+ string displayTitle,
+ string? displayDescription,
+ string? targetId,
+ Control? targetControl,
+ bool isPageResult,
+ IEnumerable? keywords = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(pageId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(pageTitle);
+ ArgumentException.ThrowIfNullOrWhiteSpace(displayTitle);
+
+ PageId = pageId.Trim();
+ PageTitle = pageTitle.Trim();
+ PageDescription = NormalizeText(pageDescription);
+ DisplayTitle = displayTitle.Trim();
+ DisplayDescription = NormalizeText(displayDescription);
+ TargetId = NormalizeText(targetId);
+ TargetControl = targetControl;
+ IsPageResult = isPageResult;
+ Keywords = keywords?
+ .Select(NormalizeText)
+ .Where(static value => !string.IsNullOrWhiteSpace(value))
+ .Select(static value => value!)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray()
+ ?? [];
+ }
+
+ public string PageId { get; }
+
+ public string PageTitle { get; }
+
+ public string? PageDescription { get; }
+
+ public string DisplayTitle { get; }
+
+ public string? DisplayDescription { get; }
+
+ public string? TargetId { get; }
+
+ public Control? TargetControl { get; }
+
+ public bool IsPageResult { get; }
+
+ public IReadOnlyList Keywords { get; }
+
+ public string SearchText => string.Join(
+ " ",
+ new[]
+ {
+ PageId,
+ PageTitle,
+ PageDescription,
+ DisplayTitle,
+ DisplayDescription,
+ TargetId,
+ string.Join(" ", Keywords)
+ }.Where(static value => !string.IsNullOrWhiteSpace(value)));
+
+ public override string ToString() => DisplayTitle;
+
+ private static string? NormalizeText(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+}
+
+internal sealed class SettingsSearchService
+{
+ private readonly Dictionary> _entriesByPage = new(StringComparer.OrdinalIgnoreCase);
+
+ public IReadOnlyList Entries =>
+ _entriesByPage.Values.SelectMany(static entries => entries).ToArray();
+
+ public void RebuildPageEntries(IEnumerable pages)
+ {
+ _entriesByPage.Clear();
+
+ foreach (var page in pages)
+ {
+ _entriesByPage[page.PageId] =
+ [
+ CreatePageResult(page)
+ ];
+ }
+ }
+
+ public void IndexPage(SettingsPageDescriptor descriptor, Control page)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(page);
+
+ var results = new List { CreatePageResult(descriptor) };
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ descriptor.PageId
+ };
+
+ foreach (var target in page.GetVisualDescendants().OfType())
+ {
+ if (target is not FASettingsExpander && target is not FASettingsExpanderItem)
+ {
+ continue;
+ }
+
+ var title = ReadControlText(target, "Header");
+ var description = ReadControlText(target, "Description");
+
+ if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(description))
+ {
+ continue;
+ }
+
+ var targetId = string.IsNullOrWhiteSpace(target.Name)
+ ? $"{descriptor.PageId}:{results.Count}"
+ : target.Name;
+ var key = $"{targetId}|{title}|{description}";
+ if (!seen.Add(key))
+ {
+ continue;
+ }
+
+ results.Add(new SettingsSearchResult(
+ descriptor.PageId,
+ descriptor.Title,
+ descriptor.Description,
+ string.IsNullOrWhiteSpace(title) ? descriptor.Title : title!,
+ description,
+ targetId,
+ target,
+ isPageResult: false,
+ keywords: [descriptor.Category.ToString(), descriptor.IconKey]));
+ }
+
+ _entriesByPage[descriptor.PageId] = results;
+ }
+
+ public IReadOnlyList Search(string? query, int maxResults = 24)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ return [];
+ }
+
+ var terms = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (terms.Length == 0)
+ {
+ return [];
+ }
+
+ return Entries
+ .Select(entry => new
+ {
+ Entry = entry,
+ Score = Score(entry, terms)
+ })
+ .Where(static item => item.Score > 0)
+ .OrderByDescending(static item => item.Score)
+ .ThenBy(static item => item.Entry.IsPageResult)
+ .ThenBy(static item => item.Entry.PageTitle, StringComparer.CurrentCultureIgnoreCase)
+ .ThenBy(static item => item.Entry.DisplayTitle, StringComparer.CurrentCultureIgnoreCase)
+ .Take(Math.Max(1, maxResults))
+ .Select(static item => item.Entry)
+ .ToArray();
+ }
+
+ public static bool Filter(string? search, object? item)
+ {
+ if (item is not SettingsSearchResult result || string.IsNullOrWhiteSpace(search))
+ {
+ return false;
+ }
+
+ var terms = search.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return terms.Length > 0 && Score(result, terms) > 0;
+ }
+
+ private static SettingsSearchResult CreatePageResult(SettingsPageDescriptor descriptor)
+ {
+ return new SettingsSearchResult(
+ descriptor.PageId,
+ descriptor.Title,
+ descriptor.Description,
+ descriptor.Title,
+ descriptor.Description,
+ descriptor.PageId,
+ null,
+ isPageResult: true,
+ keywords:
+ [
+ descriptor.Category.ToString(),
+ descriptor.IconKey,
+ descriptor.PluginId ?? string.Empty,
+ descriptor.GroupId ?? string.Empty
+ ]);
+ }
+
+ private static int Score(SettingsSearchResult entry, IReadOnlyList terms)
+ {
+ var score = 0;
+ foreach (var term in terms)
+ {
+ if (entry.DisplayTitle.StartsWith(term, StringComparison.OrdinalIgnoreCase))
+ {
+ score += 100;
+ continue;
+ }
+
+ if (entry.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase))
+ {
+ score += 75;
+ continue;
+ }
+
+ if (entry.PageTitle.Contains(term, StringComparison.OrdinalIgnoreCase))
+ {
+ score += 50;
+ continue;
+ }
+
+ if (entry.SearchText.Contains(term, StringComparison.OrdinalIgnoreCase))
+ {
+ score += 25;
+ continue;
+ }
+
+ return 0;
+ }
+
+ return score + (entry.IsPageResult ? 0 : 12);
+ }
+
+ private static string? ReadControlText(Control control, string propertyName)
+ {
+ var value = control.GetType().GetProperty(propertyName)?.GetValue(control);
+ return value switch
+ {
+ null => null,
+ string text => string.IsNullOrWhiteSpace(text) ? null : text.Trim(),
+ TextBlock textBlock => string.IsNullOrWhiteSpace(textBlock.Text) ? null : textBlock.Text.Trim(),
+ _ => value.ToString()
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/SingleInstanceService.cs b/LanMountainDesktop/Services/SingleInstanceService.cs
deleted file mode 100644
index 8ae504f..0000000
--- a/LanMountainDesktop/Services/SingleInstanceService.cs
+++ /dev/null
@@ -1,218 +0,0 @@
-using System;
-using System.IO.Pipes;
-using System.Security.Cryptography;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace LanMountainDesktop.Services;
-
-public sealed class SingleInstanceService : IDisposable
-{
- private const byte ActivationRequestCode = 0x41; // 'A'
- private const byte ActivationAckCode = 0x4B; // 'K'
- private const byte ActivationNackCode = 0x4E; // 'N'
-
- private readonly Mutex _mutex;
- private readonly string _pipeName;
- private readonly CancellationTokenSource _listenCts = new();
- private readonly ManualResetEventSlim _listenerReady = new(false);
- private bool _ownsMutex;
- private bool _disposed;
- private Task? _listenTask;
-
- private SingleInstanceService(string mutexName, string pipeName)
- {
- _mutex = new Mutex(initiallyOwned: false, mutexName);
- _pipeName = pipeName;
- try
- {
- _ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
- }
- catch (AbandonedMutexException)
- {
- _ownsMutex = true;
- }
- }
-
- public bool IsPrimaryInstance => _ownsMutex;
-
- public static SingleInstanceService CreateDefault()
- {
- const string appId = "LanMountainDesktop";
- var userName = Environment.UserName;
- var scopeSeed = $"{appId}:{userName}";
- var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed)));
- var suffix = scopeHash[..16];
- var mutexName = OperatingSystem.IsWindows()
- ? $"Local\\{appId}.SingleInstance.{suffix}"
- : $"{appId}.SingleInstance.{suffix}";
- return new SingleInstanceService(
- mutexName,
- $"{appId}.Activate.{suffix}");
- }
-
- public void StartActivationListener(Action onActivationRequested)
- {
- ArgumentNullException.ThrowIfNull(onActivationRequested);
-
- if (!_ownsMutex || _disposed || _listenTask is not null)
- {
- return;
- }
-
- AppLogger.Info(
- "SingleInstance",
- $"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
- _listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
- _listenerReady.Wait(TimeSpan.FromMilliseconds(500));
- }
-
- public bool TryNotifyPrimaryInstance(TimeSpan timeout)
- {
- return TryNotifyPrimaryInstance(timeout, out _);
- }
-
- public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
- {
- if (_ownsMutex || _disposed)
- {
- failureReason = _ownsMutex
- ? "current_instance_is_primary"
- : "single_instance_service_disposed";
- return false;
- }
-
- try
- {
- using var client = new NamedPipeClientStream(
- serverName: ".",
- pipeName: _pipeName,
- direction: PipeDirection.InOut,
- options: PipeOptions.Asynchronous);
-
- client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
- client.WriteByte(ActivationRequestCode);
- client.Flush();
-
- var ack = client.ReadByte();
- var acknowledged = ack == ActivationAckCode;
- if (!acknowledged)
- {
- failureReason = ack switch
- {
- ActivationNackCode => "primary_rejected_activation",
- -1 => "ack_not_received",
- _ => $"unexpected_ack_code_{ack}"
- };
- AppLogger.Warn(
- "SingleInstance",
- $"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
- return false;
- }
-
- failureReason = null;
- AppLogger.Info(
- "SingleInstance",
- $"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
- return true;
- }
- catch (Exception ex)
- {
- failureReason = "primary_activation_handshake_exception";
- AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
- return false;
- }
- }
-
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- _disposed = true;
- _listenCts.Cancel();
- try
- {
- _listenTask?.Wait(TimeSpan.FromSeconds(1));
- }
- catch
- {
- // Ignore listener shutdown races during process exit.
- }
-
- _listenCts.Dispose();
- _listenerReady.Dispose();
- if (_ownsMutex)
- {
- try
- {
- _mutex.ReleaseMutex();
- }
- catch (ApplicationException)
- {
- // Ownership may already be lost during shutdown.
- }
- }
-
- _mutex.Dispose();
- }
-
- private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken)
- {
- while (!cancellationToken.IsCancellationRequested)
- {
- try
- {
- using var server = new NamedPipeServerStream(
- _pipeName,
- PipeDirection.InOut,
- 1,
- PipeTransmissionMode.Byte,
- PipeOptions.Asynchronous);
-
- _listenerReady.Set();
- await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
- var buffer = new byte[1];
- var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
- var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
- var ackCode = ActivationAckCode;
-
- if (!isActivationRequest)
- {
- ackCode = ActivationNackCode;
- AppLogger.Warn(
- "SingleInstance",
- $"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
- }
- else
- {
- try
- {
- onActivationRequested();
- }
- catch (Exception ex)
- {
- ackCode = ActivationNackCode;
- AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
- }
- }
-
- var ackBuffer = new[] { ackCode };
- await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
- await server.FlushAsync(cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (Exception ex)
- {
- AppLogger.Warn("SingleInstance", "Activation listener failed.", ex);
- await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
- }
- }
- }
-}
diff --git a/LanMountainDesktop/Services/ThemeAppearanceValues.cs b/LanMountainDesktop/Services/ThemeAppearanceValues.cs
index 19553a2..d6d1ca9 100644
--- a/LanMountainDesktop/Services/ThemeAppearanceValues.cs
+++ b/LanMountainDesktop/Services/ThemeAppearanceValues.cs
@@ -10,6 +10,10 @@ public static class ThemeAppearanceValues
public const string ColorModeSeedMonet = "seed_monet";
public const string ColorModeWallpaperMonet = "wallpaper_monet";
+ public const string WallpaperColorSourceAuto = "auto";
+ public const string WallpaperColorSourceApp = "app";
+ public const string WallpaperColorSourceSystem = "system";
+
public const string ColorSchemeFollowSystem = "follow_system";
public const string ColorSchemeNative = "native";
@@ -18,6 +22,7 @@ public static class ThemeAppearanceValues
public const string ThemeModeFollowSystem = "follow_system";
public const string MaterialNone = "none";
+ public const string MaterialAuto = "auto";
public const string MaterialMica = "mica";
public const string MaterialAcrylic = "acrylic";
@@ -30,11 +35,19 @@ public static class ThemeAppearanceValues
public static readonly IReadOnlyList AllMaterialModes =
[
+ MaterialAuto,
MaterialNone,
MaterialMica,
MaterialAcrylic
];
+ public static readonly IReadOnlyList AllWallpaperColorSources =
+ [
+ WallpaperColorSourceAuto,
+ WallpaperColorSourceApp,
+ WallpaperColorSourceSystem
+ ];
+
public static string NormalizeThemeColorMode(string? value, string? themeColor = null)
{
if (string.Equals(value, ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase))
@@ -59,6 +72,11 @@ public static class ThemeAppearanceValues
public static string NormalizeSystemMaterialMode(string? value)
{
+ if (string.Equals(value, MaterialAuto, StringComparison.OrdinalIgnoreCase))
+ {
+ return MaterialAuto;
+ }
+
if (string.Equals(value, MaterialMica, StringComparison.OrdinalIgnoreCase))
{
return MaterialMica;
@@ -72,11 +90,47 @@ public static class ThemeAppearanceValues
return MaterialNone;
}
+ public static string NormalizeWallpaperColorSource(string? value)
+ {
+ if (string.Equals(value, WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase))
+ {
+ return WallpaperColorSourceApp;
+ }
+
+ if (string.Equals(value, WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase))
+ {
+ return WallpaperColorSourceSystem;
+ }
+
+ return WallpaperColorSourceAuto;
+ }
+
+ public static string ResolveEffectiveSystemMaterialMode(string? value)
+ {
+ var normalized = NormalizeSystemMaterialMode(value);
+ if (!string.Equals(normalized, MaterialAuto, StringComparison.OrdinalIgnoreCase))
+ {
+ return normalized;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000))
+ {
+ return MaterialMica;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0))
+ {
+ return MaterialAcrylic;
+ }
+
+ return MaterialNone;
+ }
+
public static IReadOnlyList NormalizeAvailableMaterialModes(IEnumerable? values)
{
if (values is null)
{
- return [MaterialNone];
+ return [MaterialAuto, MaterialNone];
}
var normalized = values
@@ -84,9 +138,14 @@ public static class ThemeAppearanceValues
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
+ if (!normalized.Contains(MaterialAuto, StringComparer.OrdinalIgnoreCase))
+ {
+ normalized.Insert(0, MaterialAuto);
+ }
+
if (!normalized.Contains(MaterialNone, StringComparer.OrdinalIgnoreCase))
{
- normalized.Insert(0, MaterialNone);
+ normalized.Insert(normalized.Count > 0 ? 1 : 0, MaterialNone);
}
return normalized;
diff --git a/LanMountainDesktop/Services/ThemeColorSystemService.cs b/LanMountainDesktop/Services/ThemeColorSystemService.cs
index eec7681..ee009fd 100644
--- a/LanMountainDesktop/Services/ThemeColorSystemService.cs
+++ b/LanMountainDesktop/Services/ThemeColorSystemService.cs
@@ -67,7 +67,7 @@ public static class ThemeColorSystemService
!isLightBackground));
}
- private static AppThemePalette BuildPalette(ThemeColorContext context)
+ public static AppThemePalette BuildPalette(ThemeColorContext context)
{
var monetPalette = context.MonetPalette;
var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
diff --git a/LanMountainDesktop/Services/Update/DeploymentLockService.cs b/LanMountainDesktop/Services/Update/DeploymentLockService.cs
new file mode 100644
index 0000000..f0be94f
--- /dev/null
+++ b/LanMountainDesktop/Services/Update/DeploymentLockService.cs
@@ -0,0 +1,52 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using LanMountainDesktop.Shared.Contracts.Update;
+
+namespace LanMountainDesktop.Services.Update;
+
+internal static class DeploymentLockService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ public static void WriteLock(string launcherRoot, DeploymentLock deploymentLock)
+ {
+ var lockPath = UpdatePaths.GetDeploymentLockPath(launcherRoot);
+ Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!);
+ var tempPath = lockPath + ".tmp";
+ File.WriteAllText(tempPath, JsonSerializer.Serialize(deploymentLock, JsonOptions));
+ File.Move(tempPath, lockPath, true);
+ }
+
+ public static DeploymentLock? ReadLock(string launcherRoot)
+ {
+ var lockPath = UpdatePaths.GetDeploymentLockPath(launcherRoot);
+ if (!File.Exists(lockPath))
+ {
+ return null;
+ }
+
+ try
+ {
+ var json = File.ReadAllText(lockPath);
+ return JsonSerializer.Deserialize(json);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UpdateLock", $"Failed to parse deployment lock: {ex.Message}");
+ return null;
+ }
+ }
+
+ public static void ClearLock(string launcherRoot)
+ {
+ var lockPath = UpdatePaths.GetDeploymentLockPath(launcherRoot);
+ if (File.Exists(lockPath))
+ {
+ File.Delete(lockPath);
+ }
+ }
+}
diff --git a/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs b/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs
index 922daa3..179552a 100644
--- a/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs
+++ b/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs
@@ -6,7 +6,7 @@ using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
-internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
+internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider, IDisposable
{
private const string ApiBasePath = "/api/plonds/v1";
@@ -51,6 +51,12 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
return null;
}
+ if (string.IsNullOrWhiteSpace(pointer.DistributionId) ||
+ string.IsNullOrWhiteSpace(pointer.Version))
+ {
+ return null;
+ }
+
return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct);
}
@@ -74,6 +80,14 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
return Task.FromResult>([]);
}
+ public void Dispose()
+ {
+ if (_ownsHttpClient)
+ {
+ _httpClient.Dispose();
+ }
+ }
+
private async Task GetChannelPointerAsync(
string channel,
string platform,
@@ -142,15 +156,17 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
foreach (var f in component.Files)
{
+ var action = FirstNonEmpty(f.Action, f.Op) ?? "add";
+ var sha256 = FirstNonEmpty(f.Sha256, f.ContentHash) ?? string.Empty;
files.Add(new UpdateFileEntry(
Path: f.Path ?? string.Empty,
- Action: f.Op ?? "add",
- Sha256: f.ContentHash ?? string.Empty,
+ Action: action,
+ Sha256: sha256,
Size: f.Size,
Mode: f.Mode ?? "file-object",
ObjectKey: f.ObjectKey,
- ObjectUrl: null,
- ArchiveSha256: null,
+ ObjectUrl: f.ObjectUrl,
+ ArchiveSha256: f.ArchiveSha256,
Metadata: null));
}
}
@@ -163,7 +179,7 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
Sha256: m.Sha256,
Size: m.Size)).ToArray();
- var fileMapSignatureUrl = dto.Signatures?.FirstOrDefault()?.Signature;
+ var fileMapSignatureUrl = FirstNonEmpty(dto.FileMapSignatureUrl, dto.Signatures?.FirstOrDefault()?.Signature);
return new UpdateManifest(
DistributionId: dto.DistributionId ?? string.Empty,
@@ -209,14 +225,15 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
private sealed record PlondsDistributionDto(
string? DistributionId,
string? Version,
- string? SourceVersion,
- string? Channel,
- string? Platform,
- DateTimeOffset PublishedAt,
- string? FileMapUrl,
- List? Components,
- List? InstallerMirrors,
- List? Signatures,
+ string? SourceVersion,
+ string? Channel,
+ string? Platform,
+ DateTimeOffset PublishedAt,
+ string? FileMapUrl,
+ string? FileMapSignatureUrl,
+ List? Components,
+ List? InstallerMirrors,
+ List? Signatures,
Dictionary? Metadata);
private sealed record PlondsComponentDto(
@@ -228,10 +245,14 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
private sealed record PlondsFileDto(
string? Path,
string? Op,
+ string? Action,
string? ContentHash,
+ string? Sha256,
long Size,
string? Mode,
- string? ObjectKey);
+ string? ObjectKey,
+ string? ObjectUrl,
+ string? ArchiveSha256);
private sealed record PlondsMirrorDto(
string? Platform,
@@ -244,4 +265,9 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
string? Algorithm,
string? KeyId,
string? Signature);
+
+ private static string? FirstNonEmpty(params string?[] values)
+ {
+ return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
+ }
}
diff --git a/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs
index 1aace67..45fefce 100644
--- a/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs
+++ b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -77,7 +78,7 @@ internal sealed class UpdateDownloadEngine
var totalFiles = downloadableFiles.Count + 2;
var completedFiles = 2;
- var seenHashes = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var seenHashes = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency));
var errors = new List();
long totalBytes = downloadableFiles.Sum(f => f.Size);
@@ -89,7 +90,7 @@ internal sealed class UpdateDownloadEngine
await semaphore.WaitAsync(ct);
try
{
- if (!seenHashes.Add(entry.Sha256))
+ if (!seenHashes.TryAdd(entry.Sha256, 0))
{
lock (lockObj)
{
@@ -146,6 +147,20 @@ internal sealed class UpdateDownloadEngine
{
AppLogger.Warn("UpdateDownloadEngine",
$"Object {entry.Path} hash mismatch after download. Expected: {entry.Sha256}, Actual: {actualHash}");
+ SafeDeleteFile(objectPath);
+
+ if (attempt < MaxRetryAttempts)
+ {
+ await Task.Delay(RetryDelayMs * attempt, ct);
+ continue;
+ }
+
+ lock (lockObj)
+ {
+ errors.Add($"Hash mismatch for {entry.Path}: expected {entry.Sha256}, actual {actualHash}");
+ }
+
+ return;
}
lock (lockObj)
@@ -274,7 +289,7 @@ internal sealed class UpdateDownloadEngine
if (result.Success)
{
- bool hashVerified;
+ bool hashVerified = true;
if (!string.IsNullOrWhiteSpace(mirror.Sha256))
{
var actualHash = await ComputeFileSha256Async(destinationPath, ct);
@@ -283,12 +298,17 @@ internal sealed class UpdateDownloadEngine
{
AppLogger.Warn("UpdateDownloadEngine",
$"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}");
+ SafeDeleteFile(destinationPath);
+
+ if (attempt < MaxRetryAttempts)
+ {
+ await Task.Delay(RetryDelayMs * attempt, ct);
+ continue;
+ }
+
+ return new DownloadResult(false, null, $"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}", false);
}
}
- else
- {
- hashVerified = false;
- }
AppLogger.Info("UpdateDownloadEngine", $"Full installer downloaded to {destinationPath}");
return new DownloadResult(true, destinationPath, null, hashVerified);
@@ -374,6 +394,20 @@ internal sealed class UpdateDownloadEngine
return Convert.ToHexString(hash).ToLowerInvariant();
}
+ private static void SafeDeleteFile(string filePath)
+ {
+ try
+ {
+ if (File.Exists(filePath))
+ {
+ File.Delete(filePath);
+ }
+ }
+ catch
+ {
+ }
+ }
+
private static string ComputeStringSha256(string content)
{
using var hasher = SHA256.Create();
diff --git a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
index ebea190..122ace8 100644
--- a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
+++ b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
@@ -2,6 +2,7 @@ using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
+using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
@@ -9,7 +10,7 @@ using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
-public sealed record InstallResult(bool Success, string? ErrorMessage, bool UserCancelledElevation);
+public sealed record InstallResult(bool Success, string? ErrorMessage, bool UserCancelledElevation, string? ErrorCode = null);
internal sealed class UpdateInstallGateway
{
@@ -31,12 +32,17 @@ internal sealed class UpdateInstallGateway
0,
0));
+ if (!VerifyDeploymentLock(payloadKind, launcherRoot, out var lockErrorCode, out var lockError))
+ {
+ return new InstallResult(false, lockError, false, lockErrorCode);
+ }
+
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
{
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
if (!launched)
{
- return new InstallResult(false, "Failed to launch Launcher for delta update application.", false);
+ return new InstallResult(false, "Failed to launch Launcher for delta update application.", false, "apply_failed");
}
progress?.Report(new InstallProgressReport(
@@ -50,10 +56,10 @@ internal sealed class UpdateInstallGateway
return new InstallResult(true, null, false);
}
- var installerPath = FindPendingInstaller(launcherRoot);
+ var installerPath = FindPendingInstaller(launcherRoot, payloadKind, ct);
if (installerPath is null)
{
- return new InstallResult(false, "No pending installer found.", false);
+ return new InstallResult(false, "No pending installer found.", false, "staging_incomplete");
}
var installerLaunched = LaunchFullInstaller(installerPath);
@@ -83,6 +89,43 @@ internal sealed class UpdateInstallGateway
}
}
+ private static bool VerifyDeploymentLock(UpdatePayloadKind payloadKind, string launcherRoot, out string? errorCode, out string? error)
+ {
+ errorCode = null;
+ error = null;
+ var deploymentLock = DeploymentLockService.ReadLock(launcherRoot);
+ if (deploymentLock is null)
+ {
+ errorCode = "lock_conflict";
+ error = "Deployment lock is missing. Please redownload the update.";
+ return false;
+ }
+
+ if (deploymentLock.SchemaVersion != 1)
+ {
+ errorCode = "lock_conflict";
+ error = "Deployment lock schema is unsupported. Please redownload the update.";
+ return false;
+ }
+
+ var expectedKind = payloadKind is UpdatePayloadKind.DeltaLegacy or UpdatePayloadKind.DeltaPlonds ? "delta" : "full";
+ if (!string.Equals(deploymentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
+ {
+ errorCode = "lock_conflict";
+ error = "Deployment lock payload type mismatch. Please redownload the update.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) || !File.Exists(deploymentLock.PayloadPath))
+ {
+ errorCode = "staging_incomplete";
+ error = "Deployment lock payload path is missing. Please redownload the update.";
+ return false;
+ }
+
+ return true;
+ }
+
private bool LaunchLauncherForApplyUpdate(string launcherRoot)
{
try
@@ -145,15 +188,27 @@ internal sealed class UpdateInstallGateway
}
}
- private static string? FindPendingInstaller(string launcherRoot)
+ private static string? FindPendingInstaller(string launcherRoot, UpdatePayloadKind payloadKind, CancellationToken ct)
{
+ ct.ThrowIfCancellationRequested();
+
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
if (!Directory.Exists(incomingDir))
{
return null;
}
- var executables = Directory.GetFiles(incomingDir, "*.exe");
- return executables.Length > 0 ? executables[0] : null;
+ var executables = new DirectoryInfo(incomingDir)
+ .EnumerateFiles("*.exe", SearchOption.TopDirectoryOnly)
+ .OrderByDescending(file => file.LastWriteTimeUtc)
+ .ThenBy(file => file.Name, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (executables.Length == 0)
+ {
+ return null;
+ }
+
+ return executables[0].FullName;
}
}
diff --git a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs
index 0747559..9c704ab 100644
--- a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs
+++ b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs
@@ -9,6 +9,34 @@ using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateS
namespace LanMountainDesktop.Services.Update;
+internal static class HostUpdateOrchestratorProvider
+{
+ private static readonly object Gate = new();
+ private static UpdateOrchestrator? _instance;
+
+ public static UpdateOrchestrator GetOrCreate()
+ {
+ lock (Gate)
+ {
+ if (_instance is not null)
+ {
+ return _instance;
+ }
+
+ var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
+ var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop");
+ var staticProvider = new PlondsApiManifestProvider("https://api.classisland.tech");
+ var compositeProvider = new CompositeManifestProvider(staticProvider, githubProvider);
+ var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) };
+ var downloadEngine = new UpdateDownloadEngine(compositeProvider, new ResumableDownloadService(httpClient));
+ var installGateway = new UpdateInstallGateway();
+ var stateStore = new UpdateStateStore(settingsFacade);
+ _instance = new UpdateOrchestrator(compositeProvider, downloadEngine, installGateway, stateStore);
+ return _instance;
+ }
+ }
+}
+
public sealed class UpdateOrchestrator : IDisposable
{
private readonly IUpdateManifestProvider _manifestProvider;
@@ -16,6 +44,8 @@ public sealed class UpdateOrchestrator : IDisposable
private readonly UpdateInstallGateway _installGateway;
private readonly UpdateStateStore _stateStore;
private readonly SemaphoreSlim _operationGate = new(1, 1);
+ private readonly object _cancellationSync = new();
+ private CancellationTokenSource? _activeOperationCts;
private bool _disposed;
internal UpdateOrchestrator(
@@ -40,9 +70,29 @@ public sealed class UpdateOrchestrator : IDisposable
public event Action? PhaseChanged;
public event Action? ProgressChanged;
+ private CancellationToken RegisterOperationCancellation(CancellationToken ct)
+ {
+ lock (_cancellationSync)
+ {
+ _activeOperationCts?.Dispose();
+ _activeOperationCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ return _activeOperationCts.Token;
+ }
+ }
+
+ private void ClearOperationCancellation()
+ {
+ lock (_cancellationSync)
+ {
+ _activeOperationCts?.Dispose();
+ _activeOperationCts = null;
+ }
+ }
+
public async Task CheckAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
+ var operationToken = RegisterOperationCancellation(ct);
try
{
if (!CurrentPhase.CanCheck())
@@ -59,19 +109,47 @@ public sealed class UpdateOrchestrator : IDisposable
var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion
?? AppVersionProvider.ResolveForCurrentProcess().Version;
- if (!Version.TryParse(currentVersionText, out var currentVersion))
+ if (!TryParseVersion(currentVersionText, out var currentVersion))
{
- currentVersion = new Version(0, 0, 0);
+ _stateStore.TransitionTo(UpdatePhase.Failed);
+ _stateStore.RecordFailure($"Invalid current version text: {currentVersionText}");
+ return new UpdateCheckReport(
+ false,
+ null,
+ currentVersionText,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ $"Invalid current version text: {currentVersionText}");
}
UpdateManifest? manifest;
try
{
- manifest = await _manifestProvider.GetLatestAsync(
- channel,
- "win-x64",
- currentVersion,
- ct);
+ var platform = LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform();
+ manifest = settings.ForceUpdateReinstall
+ ? await _manifestProvider.GetByVersionAsync(
+ currentVersionText,
+ channel,
+ platform,
+ operationToken)
+ : await _manifestProvider.GetLatestAsync(
+ channel,
+ platform,
+ currentVersion,
+ operationToken);
+
+ if (manifest is null && settings.ForceUpdateReinstall)
+ {
+ manifest = await _manifestProvider.GetLatestAsync(
+ channel,
+ platform,
+ currentVersion,
+ operationToken);
+ }
}
catch (OperationCanceledException)
{
@@ -114,6 +192,7 @@ public sealed class UpdateOrchestrator : IDisposable
}
finally
{
+ ClearOperationCancellation();
_operationGate.Release();
}
}
@@ -121,9 +200,10 @@ public sealed class UpdateOrchestrator : IDisposable
public async Task DownloadAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
+ var operationToken = RegisterOperationCancellation(ct);
try
{
- if (!CurrentPhase.CanDownload())
+ if (CurrentPhase is not (UpdatePhase.Checked or UpdatePhase.PausedDownloading))
{
return new DownloadResult(false, null, $"Cannot download in phase {CurrentPhase}.", false);
}
@@ -168,7 +248,7 @@ public sealed class UpdateOrchestrator : IDisposable
objectsDir,
maxThreads,
downloadProgress,
- ct);
+ operationToken);
}
else
{
@@ -183,7 +263,7 @@ public sealed class UpdateOrchestrator : IDisposable
destinationPath,
maxThreads,
downloadProgress,
- ct);
+ operationToken);
}
if (result.Success)
@@ -196,9 +276,19 @@ public sealed class UpdateOrchestrator : IDisposable
PendingUpdateInstallerPath = result.FilePath,
PendingUpdateVersion = manifest.ToVersion,
PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(),
- PendingUpdateSha256 = null
+ PendingUpdateSha256 = null,
+ LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
+ var payloadKind = manifest.IsDelta ? "delta" : "full";
+ DeploymentLockService.WriteLock(launcherRoot, new DeploymentLock(
+ SchemaVersion: 1,
+ Kind: payloadKind,
+ TargetVersion: manifest.ToVersion,
+ PayloadPath: result.FilePath ?? string.Empty,
+ PayloadSha256: null,
+ CreatedAtUtc: DateTimeOffset.UtcNow));
+
AppLogger.Info("UpdateOrchestrator", $"Update downloaded successfully: {manifest.ToVersion}");
}
else
@@ -211,7 +301,11 @@ public sealed class UpdateOrchestrator : IDisposable
}
catch (OperationCanceledException)
{
- _stateStore.TransitionTo(UpdatePhase.Idle);
+ if (CurrentPhase != UpdatePhase.PausedDownloading)
+ {
+ _stateStore.TransitionTo(UpdatePhase.Idle);
+ }
+
throw;
}
catch (Exception ex)
@@ -223,6 +317,7 @@ public sealed class UpdateOrchestrator : IDisposable
}
finally
{
+ ClearOperationCancellation();
_operationGate.Release();
}
}
@@ -230,17 +325,18 @@ public sealed class UpdateOrchestrator : IDisposable
public async Task InstallAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
+ var operationToken = RegisterOperationCancellation(ct);
try
{
if (!CurrentPhase.CanInstall())
{
- return new InstallResult(false, $"Cannot install in phase {CurrentPhase}.", false);
+ return new InstallResult(false, $"Cannot install in phase {CurrentPhase}.", false, "invalid_phase");
}
var manifest = _stateStore.PendingManifest;
if (manifest is null)
{
- return new InstallResult(false, "No manifest available for install.", false);
+ return new InstallResult(false, "No manifest available for install.", false, "staging_incomplete");
}
_stateStore.TransitionTo(UpdatePhase.Installing);
@@ -264,7 +360,7 @@ public sealed class UpdateOrchestrator : IDisposable
manifest.Kind,
launcherRoot,
installProgress,
- ct);
+ operationToken);
if (result.Success)
{
@@ -282,18 +378,23 @@ public sealed class UpdateOrchestrator : IDisposable
}
catch (OperationCanceledException)
{
- _stateStore.TransitionTo(UpdatePhase.Failed);
+ if (CurrentPhase != UpdatePhase.PausedInstalling)
+ {
+ _stateStore.TransitionTo(UpdatePhase.Idle);
+ }
+
throw;
}
catch (Exception ex)
{
_stateStore.TransitionTo(UpdatePhase.Failed);
_stateStore.RecordFailure(ex.Message);
- return new InstallResult(false, ex.Message, false);
+ return new InstallResult(false, ex.Message, false, "install_exception");
}
}
finally
{
+ ClearOperationCancellation();
_operationGate.Release();
}
}
@@ -301,8 +402,11 @@ public sealed class UpdateOrchestrator : IDisposable
public async Task RollbackAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
+ var operationToken = RegisterOperationCancellation(ct);
try
{
+ operationToken.ThrowIfCancellationRequested();
+
if (!CurrentPhase.CanRollback())
{
return;
@@ -330,6 +434,11 @@ public sealed class UpdateOrchestrator : IDisposable
_stateStore.TransitionTo(UpdatePhase.RolledBack);
}
+ catch (OperationCanceledException)
+ {
+ _stateStore.TransitionTo(UpdatePhase.Idle);
+ throw;
+ }
catch (Exception ex)
{
AppLogger.Warn("UpdateOrchestrator", $"Rollback failed: {ex.Message}");
@@ -338,21 +447,86 @@ public sealed class UpdateOrchestrator : IDisposable
}
finally
{
+ ClearOperationCancellation();
_operationGate.Release();
}
}
- public async Task CancelAsync()
+ public Task CancelAsync()
{
- if (!CurrentPhase.IsBusy())
+ if (!CurrentPhase.CanCancel())
{
- return;
+ return Task.CompletedTask;
+ }
+
+ lock (_cancellationSync)
+ {
+ _activeOperationCts?.Cancel();
}
_stateStore.TransitionTo(UpdatePhase.Idle);
- _stateStore.PendingManifest = null;
- AppLogger.Info("UpdateOrchestrator", "Update operation cancelled.");
- await Task.CompletedTask;
+
+ var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
+ CleanupIncomingArtifacts(launcherRoot);
+ DeploymentLockService.ClearLock(launcherRoot);
+
+ var state = _stateStore.GetSettings();
+ _stateStore.SaveSettings(state with
+ {
+ PendingUpdateInstallerPath = null,
+ PendingUpdateVersion = null,
+ PendingUpdateSha256 = null
+ });
+
+ AppLogger.Info("UpdateOrchestrator", "Cancellation requested for active update operation.");
+ return Task.CompletedTask;
+ }
+
+ public Task PauseAsync()
+ {
+ if (!CurrentPhase.CanPause())
+ {
+ return Task.CompletedTask;
+ }
+
+ var pausedPhase = CurrentPhase switch
+ {
+ UpdatePhase.Downloading => UpdatePhase.PausedDownloading,
+ UpdatePhase.Installing => UpdatePhase.PausedInstalling,
+ _ => UpdatePhase.Idle
+ };
+
+ _stateStore.TransitionTo(pausedPhase);
+
+ lock (_cancellationSync)
+ {
+ _activeOperationCts?.Cancel();
+ }
+
+ AppLogger.Info("UpdateOrchestrator", $"Pause requested in phase {pausedPhase}.");
+ return Task.CompletedTask;
+ }
+
+ public async Task ResumeAsync(CancellationToken ct)
+ {
+ return CurrentPhase switch
+ {
+ UpdatePhase.PausedDownloading => await DownloadAsync(ct),
+ UpdatePhase.PausedInstalling => await ResumeInstallAsync(ct),
+ _ => new DownloadResult(false, null, $"Cannot resume in phase {CurrentPhase}.", false)
+ };
+ }
+
+ private async Task ResumeInstallAsync(CancellationToken ct)
+ {
+ _stateStore.TransitionTo(UpdatePhase.Recovering);
+ var installResult = await InstallAsync(ct);
+ if (installResult.Success)
+ {
+ return new DownloadResult(true, null, null, false);
+ }
+
+ return new DownloadResult(false, null, installResult.ErrorMessage ?? installResult.ErrorCode ?? "Install resume failed.", false);
}
public async Task AutoCheckIfEnabledAsync(CancellationToken ct)
@@ -367,7 +541,13 @@ public sealed class UpdateOrchestrator : IDisposable
try
{
- await CheckAsync(ct);
+ var report = await CheckAsync(ct);
+ if (!report.IsUpdateAvailable || !CurrentPhase.CanDownload())
+ {
+ return;
+ }
+
+ await DownloadAsync(ct);
}
catch (OperationCanceledException)
{
@@ -458,6 +638,77 @@ public sealed class UpdateOrchestrator : IDisposable
}
}
+ private static void CleanupIncomingArtifacts(string launcherRoot)
+ {
+ var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
+
+ foreach (var path in new[]
+ {
+ Path.Combine(incomingDir, UpdatePaths.GetLegacyFileMapName()),
+ Path.Combine(incomingDir, UpdatePaths.GetLegacySignatureName()),
+ Path.Combine(incomingDir, UpdatePaths.GetLegacyArchiveName()),
+ Path.Combine(incomingDir, UpdatePaths.GetPlondsFileMapName()),
+ Path.Combine(incomingDir, UpdatePaths.GetPlondsSignatureName()),
+ Path.Combine(incomingDir, UpdatePaths.GetPlondsUpdateMetadataName()),
+ UpdatePaths.GetDownloadMarkerPath(launcherRoot)
+ })
+ {
+ try
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ try
+ {
+ var objectsDir = UpdatePaths.GetObjectsDirectory(launcherRoot);
+ if (Directory.Exists(objectsDir))
+ {
+ Directory.Delete(objectsDir, true);
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ private static bool TryParseVersion(string? value, out Version version)
+ {
+ version = new Version(0, 0, 0);
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var normalized = value.Trim().TrimStart('v', 'V');
+ var dashIndex = normalized.IndexOf('-');
+ if (dashIndex >= 0)
+ {
+ normalized = normalized[..dashIndex];
+ }
+
+ var plusIndex = normalized.IndexOf('+');
+ if (plusIndex >= 0)
+ {
+ normalized = normalized[..plusIndex];
+ }
+
+ if (!Version.TryParse(normalized, out var parsed))
+ {
+ return false;
+ }
+
+ version = new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build), Math.Max(0, parsed.Revision));
+ return true;
+ }
+
private void OnPhaseChanged(UpdatePhase phase)
{
PhaseChanged?.Invoke(phase);
@@ -478,6 +729,11 @@ public sealed class UpdateOrchestrator : IDisposable
_disposed = true;
_stateStore.PhaseChanged -= OnPhaseChanged;
_stateStore.ProgressChanged -= OnProgressChanged;
+ lock (_cancellationSync)
+ {
+ _activeOperationCts?.Dispose();
+ _activeOperationCts = null;
+ }
_operationGate.Dispose();
}
}
diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs
index 7af27a0..303a2da 100644
--- a/LanMountainDesktop/Services/UpdateSettingsValues.cs
+++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs
@@ -10,6 +10,8 @@ public static class UpdateSettingsValues
public const string ModeManual = "manual";
public const string ModeDownloadThenConfirm = "download_then_confirm";
public const string ModeSilentOnExit = "silent_on_exit";
+ public const string ModeSilentDownload = ModeDownloadThenConfirm;
+ public const string ModeSilentInstall = ModeSilentOnExit;
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePlonds = "plonds-api";
@@ -20,6 +22,8 @@ public static class UpdateSettingsValues
public const string LegacyDownloadSourceStcn = "stcn";
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
+ public const string PlondsStaticBaseUrlEnvironmentVariable = "LANMOUNTAIN_UPDATE_BASE_URL";
+ public const string DefaultPlondsStaticBaseUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update";
public const int DefaultDownloadThreads = 4;
public const int MinDownloadThreads = 1;
diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs
deleted file mode 100644
index 79e3eaf..0000000
--- a/LanMountainDesktop/Services/UpdateWorkflowService.cs
+++ /dev/null
@@ -1,1572 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel;
-using System.Diagnostics;
-using System.Globalization;
-using System.IO;
-using System.IO.Compression;
-using System.Linq;
-using System.Net.Http;
-using System.Runtime.InteropServices;
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using LanMountainDesktop.PluginSdk;
-using LanMountainDesktop.Services.Settings;
-
-namespace LanMountainDesktop.Services;
-
-public sealed record UpdatePendingInfo(
- string InstallerPath,
- string VersionText,
- DateTimeOffset? PublishedAt,
- string? Sha256 = null);
-
-public sealed record UpdateVerifyResult(
- bool Success,
- bool HashMatched,
- string? ExpectedHash,
- string? ActualHash,
- string? ErrorMessage);
-
-public sealed record UpdateInstallerLaunchResult(
- bool Success,
- bool UserCancelledElevation,
- string? ErrorMessage);
-
-internal static class HostUpdateWorkflowServiceProvider
-{
- private static readonly object Gate = new();
- private static UpdateWorkflowService? _instance;
-
- public static UpdateWorkflowService GetOrCreate()
- {
- lock (Gate)
- {
- return _instance ??= new UpdateWorkflowService(HostSettingsFacadeProvider.GetOrCreate());
- }
- }
-}
-
-public sealed class UpdateWorkflowService
-{
- private readonly ISettingsFacadeService _settingsFacade;
- private readonly string _updatesDirectory;
-
- private const string LauncherDirectoryName = ".launcher";
- private const string UpdateDirectoryName = "update";
- private const string IncomingDirectoryName = "incoming";
- private const string IncomingObjectsDirectoryName = "objects";
- private const string SignedFileMapName = "files.json";
- private const string SignedFileMapSignatureName = "files.json.sig";
- private const string UpdateArchiveName = "update.zip";
- private const string PlondsFileMapName = "plonds-filemap.json";
- private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
- private const string PlondsUpdateStateName = "plonds-update.json";
- private const string PlondsUpdateArchiveName = "plonds-update.zip";
-
- private static readonly HttpClient PlondsHttpClient = new()
- {
- Timeout = TimeSpan.FromMinutes(5)
- };
-
- private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
- private const int MaxPlondsOuterRetryAttempts = 3;
-
- public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
- {
- _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
- _updatesDirectory = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "LanMountainDesktop",
- "Updates");
- }
-
- ///
- /// Gets the path to the Launcher's incoming update directory where delta packages should be placed.
- ///
- public static string GetLauncherIncomingDirectory()
- {
- // The app runs from app-{version}/ subdirectory; Launcher root is one level up.
- var appBaseDir = AppContext.BaseDirectory;
- var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
- if (string.IsNullOrWhiteSpace(launcherRoot))
- {
- launcherRoot = appBaseDir;
- }
- return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
- }
-
- public static string GetLauncherIncomingObjectsDirectory()
- {
- return Path.Combine(GetLauncherIncomingDirectory(), IncomingObjectsDirectoryName);
- }
-
- ///
- /// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates.
- ///
- public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
- {
- if (release is null || release.Assets is null || release.Assets.Count == 0)
- {
- return false;
- }
-
- return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
- }
-
- public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult)
- {
- if (checkResult.PlondsPayload is not null)
- {
- return true;
- }
-
- return checkResult.Release is not null && IsDeltaUpdateAvailable(checkResult.Release);
- }
-
- ///
- /// Downloads signed file-map assets to the Launcher's incoming directory.
- ///
- public async Task DownloadDeltaUpdateAsync(
- UpdateCheckResult checkResult,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- ArgumentNullException.ThrowIfNull(checkResult);
-
- if (!checkResult.Success || !checkResult.IsUpdateAvailable)
- {
- return new UpdateDownloadResult(false, null, "No update available for delta download.");
- }
-
- if (checkResult.PlondsPayload is null && checkResult.Release is null)
- {
- return new UpdateDownloadResult(false, null, "No update payload is available for delta download.");
- }
-
- if (checkResult.PlondsPayload is not null)
- {
- return await DownloadPlondsDeltaUpdateAsync(checkResult, progress, cancellationToken);
- }
-
- var release = checkResult.Release;
- if (release is null ||
- !TryResolveDeltaAssets(release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
- {
- return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
- }
-
- var incomingDir = GetLauncherIncomingDirectory();
-
- try
- {
- Directory.CreateDirectory(incomingDir);
- }
- catch (Exception ex)
- {
- return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
- }
-
- var state = _settingsFacade.Update.Get();
- var downloadSource = state.UseGhProxyMirror
- ? UpdateSettingsValues.DownloadSourceGhProxy
- : UpdateSettingsValues.DownloadSourceGitHub;
- var downloadThreads = state.UpdateDownloadThreads;
-
- var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
- {
- (manifestAsset, SignedFileMapName),
- (signatureAsset, SignedFileMapSignatureName),
- (archiveAsset, UpdateArchiveName)
- };
-
- var totalAssets = requiredAssets.Count;
- var completedAssets = 0;
-
- foreach (var (asset, destinationFileName) in requiredAssets)
- {
- var destinationPath = Path.Combine(incomingDir, destinationFileName);
-
- // Skip if already downloaded and file exists
- if (File.Exists(destinationPath))
- {
- var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
- if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
- {
- AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
- completedAssets++;
- progress?.Report((double)completedAssets / totalAssets);
- continue;
- }
- }
-
- var assetProgress = progress is null ? null : new Progress(p =>
- {
- var overallProgress = ((double)completedAssets + p) / totalAssets;
- progress.Report(overallProgress);
- });
-
- var result = await _settingsFacade.Update.DownloadAssetAsync(
- asset,
- destinationPath,
- downloadSource,
- downloadThreads,
- assetProgress,
- cancellationToken);
-
- if (!result.Success)
- {
- // Clean up partially downloaded files
- foreach (var file in requiredAssets.Select(a => a.DestinationFileName))
- {
- try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
- }
- return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}");
- }
-
- completedAssets++;
- progress?.Report((double)completedAssets / totalAssets);
- }
-
- // Save state indicating a signed file-map update is pending.
- SaveState(state with
- {
- PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
- PendingUpdateVersion = checkResult.LatestVersionText,
- PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
- ? publishedAt.ToUnixTimeMilliseconds()
- : null,
- LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- PendingUpdateSha256 = null
- });
-
- AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
-
- return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
- }
-
- private async Task DownloadPlondsDeltaUpdateAsync(
- UpdateCheckResult checkResult,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- var payload = checkResult.PlondsPayload;
- if (payload is null)
- {
- return await HandlePlondsDeltaFailureAsync(
- checkResult,
- "payload-parse",
- "PLONDS payload is missing.",
- progress,
- cancellationToken);
- }
-
- var incomingDir = GetLauncherIncomingDirectory();
- var objectsDir = GetLauncherIncomingObjectsDirectory();
-
- try
- {
- Directory.CreateDirectory(incomingDir);
- Directory.CreateDirectory(objectsDir);
- }
- catch (Exception ex)
- {
- return await HandlePlondsDeltaFailureAsync(
- checkResult,
- "payload-parse",
- $"Failed to create incoming directory: {ex.Message}",
- progress,
- cancellationToken);
- }
-
- try
- {
- var state = _settingsFacade.Update.Get();
- var downloadThreads = Math.Max(1, state.UpdateDownloadThreads);
- var fileMapPath = Path.Combine(incomingDir, PlondsFileMapName);
- var signaturePath = Path.Combine(incomingDir, PlondsFileMapSignatureName);
- var updateStatePath = Path.Combine(incomingDir, PlondsUpdateStateName);
-
- var fileMapJson = await EnsurePlondsTextResourceAsync(
- payload.FileMapJson,
- payload.FileMapJsonUrl,
- fileMapPath,
- "file map",
- "filemap-download",
- cancellationToken);
-
- var fileMapSignature = await EnsurePlondsTextResourceAsync(
- payload.FileMapSignature,
- payload.FileMapSignatureUrl,
- signaturePath,
- "file map signature",
- "filemap-download",
- cancellationToken);
-
- IReadOnlyList objectResults;
- if (!string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl))
- {
- progress?.Report(2d / 3d);
- objectResults = await EnsurePlondsArchiveObjectsAsync(
- payload,
- incomingDir,
- objectsDir,
- state.UseGhProxyMirror
- ? UpdateSettingsValues.DownloadSourceGhProxy
- : UpdateSettingsValues.DownloadSourceGitHub,
- downloadThreads,
- progress,
- cancellationToken);
- }
- else
- {
- IReadOnlyList downloadEntries;
- try
- {
- downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
- }
- catch (JsonException ex)
- {
- throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
- }
-
- if (downloadEntries.Count == 0)
- {
- throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
- }
-
- var expectedObjectCount = downloadEntries.Count;
- var completedItems = 2;
- progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2));
-
- var downloadResults = new List(expectedObjectCount);
- var objectTargets = new HashSet(StringComparer.OrdinalIgnoreCase);
- var totalSteps = expectedObjectCount + 2;
-
- foreach (var entry in downloadEntries)
- {
- if (!objectTargets.Add(entry.ObjectHashHex))
- {
- completedItems++;
- progress?.Report((double)completedItems / totalSteps);
- continue;
- }
-
- var objectInfo = await EnsurePlondsObjectAsync(
- entry,
- objectsDir,
- downloadThreads,
- cancellationToken);
-
- downloadResults.Add(objectInfo);
- completedItems++;
- progress?.Report((double)completedItems / totalSteps);
- }
-
- objectResults = downloadResults;
- }
-
- var updateState = new PlondsUpdateState(
- checkResult.LatestVersionText,
- payload.DistributionId,
- payload.ChannelId,
- payload.SubChannel,
- fileMapPath,
- signaturePath,
- objectsDir,
- DateTimeOffset.UtcNow,
- fileMapJson,
- fileMapSignature,
- objectResults);
-
- await File.WriteAllTextAsync(updateStatePath, JsonSerializer.Serialize(updateState, UpdateJsonOptions), cancellationToken);
-
- SaveState(state with
- {
- PendingUpdateInstallerPath = updateStatePath,
- PendingUpdateVersion = checkResult.LatestVersionText,
- PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
- ? publishedAt.ToUnixTimeMilliseconds()
- : null,
- LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- PendingUpdateSha256 = null
- });
-
- progress?.Report(1d);
- AppLogger.Info("UpdateWorkflow", $"PLONDS update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
- return new UpdateDownloadResult(true, updateStatePath, null);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception ex)
- {
- var stage = ex is PlondsDownloadException plondsException
- ? plondsException.Stage
- : "payload-parse";
- var message = ex is PlondsDownloadException
- ? ex.Message
- : $"PLONDS incremental payload failed unexpectedly: {ex.Message}";
-
- AppLogger.Warn("UpdateWorkflow", $"Failed to download PLONDS incremental payload at stage '{stage}'.", ex);
- return await HandlePlondsDeltaFailureAsync(
- checkResult,
- stage,
- message,
- progress,
- cancellationToken);
- }
- }
-
- private static readonly JsonSerializerOptions UpdateJsonOptions = new()
- {
- WriteIndented = true
- };
-
- ///
- /// Checks whether the pending update is managed by Launcher incoming payload.
- ///
- public bool IsPendingDeltaUpdate()
- {
- var state = _settingsFacade.Update.Get();
- var pendingPath = state.PendingUpdateInstallerPath?.Trim();
- if (string.IsNullOrWhiteSpace(pendingPath))
- {
- return false;
- }
-
- // Incoming payload updates are identified by the local manifest or incoming directory path.
- return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
- || pendingPath.EndsWith(PlondsUpdateStateName, StringComparison.OrdinalIgnoreCase)
- || pendingPath.EndsWith(PlondsFileMapName, StringComparison.OrdinalIgnoreCase)
- || pendingPath.EndsWith(PlondsFileMapSignatureName, StringComparison.OrdinalIgnoreCase)
- || pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
- }
-
- private async Task DownloadFullInstallerAsync(
- UpdateCheckResult checkResult,
- IProgress? progress,
- CancellationToken cancellationToken,
- bool forceRedownload)
- {
- if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
- {
- return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
- }
-
- var state = _settingsFacade.Update.Get();
- var existingPending = GetPendingUpdate(state);
-
- if (!forceRedownload &&
- existingPending is not null &&
- string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
- File.Exists(existingPending.InstallerPath))
- {
- var verifyResult = await VerifyPendingUpdateAsync();
- if (verifyResult.Success)
- {
- return new UpdateDownloadResult(
- true,
- existingPending.InstallerPath,
- null,
- verifyResult.HashMatched,
- verifyResult.ExpectedHash,
- verifyResult.ActualHash);
- }
-
- AppLogger.Warn(
- "UpdateWorkflow",
- $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
- }
-
- if (forceRedownload && existingPending is not null && File.Exists(existingPending.InstallerPath))
- {
- try
- {
- File.Delete(existingPending.InstallerPath);
- AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
- }
- catch (Exception ex)
- {
- AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
- }
-
- ClearPendingUpdate();
- state = _settingsFacade.Update.Get();
- }
-
- Directory.CreateDirectory(_updatesDirectory);
- var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
- var destinationPath = Path.Combine(_updatesDirectory, fileName);
-
- var result = await _settingsFacade.Update.DownloadAssetAsync(
- checkResult.PreferredAsset,
- destinationPath,
- state.UseGhProxyMirror
- ? UpdateSettingsValues.DownloadSourceGhProxy
- : UpdateSettingsValues.DownloadSourceGitHub,
- state.UpdateDownloadThreads,
- progress,
- cancellationToken);
-
- if (result.Success)
- {
- SaveState(state with
- {
- PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
- PendingUpdateVersion = checkResult.LatestVersionText,
- PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
- ? publishedAt.ToUnixTimeMilliseconds()
- : null,
- LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- PendingUpdateSha256 = result.ActualHash
- });
- }
-
- return result;
- }
-
- private async Task HandlePlondsDeltaFailureAsync(
- UpdateCheckResult checkResult,
- string stage,
- string errorMessage,
- IProgress? progress,
- CancellationToken cancellationToken)
- {
- var normalizedMessage = string.IsNullOrWhiteSpace(errorMessage)
- ? $"PLONDS {stage} failed."
- : $"PLONDS {stage} failed: {errorMessage}";
-
- if (checkResult.Release is null || checkResult.PreferredAsset is null)
- {
- return new UpdateDownloadResult(false, null, normalizedMessage);
- }
-
- AppLogger.Warn(
- "UpdateWorkflow",
- $"PLONDS delta download failed at stage '{stage}'. Falling back to full installer download. Details: {errorMessage}");
-
- var fallbackResult = await DownloadFullInstallerAsync(
- checkResult,
- progress,
- cancellationToken,
- forceRedownload: false);
-
- if (fallbackResult.Success)
- {
- return fallbackResult;
- }
-
- var combinedMessage = string.IsNullOrWhiteSpace(fallbackResult.ErrorMessage)
- ? normalizedMessage
- : $"{normalizedMessage} Full installer fallback failed: {fallbackResult.ErrorMessage}";
-
- return new UpdateDownloadResult(false, null, combinedMessage);
- }
-
- private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex)
- {
- var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
- var shard = normalizedHash.Length >= 2 ? normalizedHash[..2] : normalizedHash;
- return Path.Combine(objectsDirectory, shard, normalizedHash);
- }
-
- private static async Task EnsurePlondsTextResourceAsync(
- string? inlineContent,
- string? sourceUrl,
- string destinationPath,
- string resourceName,
- string stage,
- CancellationToken cancellationToken)
- {
- if (!string.IsNullOrWhiteSpace(inlineContent))
- {
- await File.WriteAllTextAsync(destinationPath, inlineContent, cancellationToken);
- return inlineContent;
- }
-
- if (string.IsNullOrWhiteSpace(sourceUrl))
- {
- throw new PlondsDownloadException(stage, $"PLONDS payload does not contain a {resourceName} source.");
- }
-
- Exception? lastError = null;
- for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
- {
- var downloadResult = await PlondsDownloadService.DownloadAsync(
- sourceUrl,
- destinationPath,
- cancellationToken: cancellationToken);
-
- if (downloadResult.Success)
- {
- try
- {
- return await File.ReadAllTextAsync(destinationPath, cancellationToken);
- }
- catch (Exception ex) when (attempt < MaxPlondsOuterRetryAttempts)
- {
- lastError = ex;
- }
- }
- else
- {
- lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS {resourceName}.");
- }
-
- if (attempt < MaxPlondsOuterRetryAttempts)
- {
- AppLogger.Warn(
- "UpdateWorkflow",
- $"PLONDS {resourceName} download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed. Retrying same URL.");
- await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
- }
- }
-
- throw new PlondsDownloadException(
- stage,
- $"Failed to download PLONDS {resourceName} from {sourceUrl}.",
- lastError);
- }
-
- private static async Task EnsurePlondsObjectAsync(
- PlondsDownloadEntry entry,
- string objectsDirectory,
- int downloadThreads,
- CancellationToken cancellationToken)
- {
- var destinationPath = GetPlondsObjectDestinationPath(objectsDirectory, entry.ObjectHashHex);
- var destinationDirectory = Path.GetDirectoryName(destinationPath);
- if (!string.IsNullOrWhiteSpace(destinationDirectory))
- {
- Directory.CreateDirectory(destinationDirectory);
- }
-
- var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
- if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
- {
- return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
- }
-
- if (!string.IsNullOrWhiteSpace(existingHash))
- {
- DeleteFileIfExists(destinationPath);
- }
-
- var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
- var allowForcedRedownload = true;
- Exception? lastError = null;
-
- for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
- {
- var downloadResult = await PlondsDownloadService.DownloadAsync(
- entry.DownloadUrl,
- destinationPath,
- downloadOptions,
- null,
- cancellationToken);
-
- if (!downloadResult.Success)
- {
- lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS object {entry.RelativePath}.");
- if (attempt < MaxPlondsOuterRetryAttempts)
- {
- AppLogger.Warn(
- "UpdateWorkflow",
- $"PLONDS object download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed for {entry.RelativePath}. Retrying.");
- await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
- continue;
- }
-
- throw new PlondsDownloadException(
- "object-download",
- $"Failed to download PLONDS object {entry.RelativePath}.",
- lastError);
- }
-
- var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
- if (!string.IsNullOrWhiteSpace(actualHash) &&
- string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
- {
- return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
- }
-
- DeleteFileIfExists(destinationPath);
- var mismatchMessage = $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash ?? ""}";
- lastError = new InvalidOperationException(mismatchMessage);
-
- if (allowForcedRedownload)
- {
- allowForcedRedownload = false;
- AppLogger.Warn(
- "UpdateWorkflow",
- $"{mismatchMessage}. Removing the bad object and forcing one clean re-download.");
- await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
- continue;
- }
-
- throw new PlondsDownloadException("object-verify", mismatchMessage, lastError);
- }
-
- throw new PlondsDownloadException(
- "object-download",
- $"Failed to download PLONDS object {entry.RelativePath}.",
- lastError);
- }
-
- private async Task> EnsurePlondsArchiveObjectsAsync(
- PlondsUpdatePayload payload,
- string incomingDirectory,
- string objectsDirectory,
- string downloadSource,
- int downloadThreads,
- IProgress? progress,
- CancellationToken cancellationToken)
- {
- if (string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl))
- {
- throw new PlondsDownloadException("payload-parse", "PLONDS payload does not contain an update archive URL.");
- }
-
- var archiveAsset = new GitHubReleaseAsset(
- Name: Path.GetFileName(payload.UpdateArchiveUrl) ?? PlondsUpdateArchiveName,
- BrowserDownloadUrl: payload.UpdateArchiveUrl,
- SizeBytes: payload.UpdateArchiveSizeBytes ?? 0,
- Sha256: payload.UpdateArchiveSha256);
- var archivePath = Path.Combine(incomingDirectory, PlondsUpdateArchiveName);
- var archiveProgress = progress is null
- ? null
- : new Progress(p => progress.Report((2d + p) / 3d));
-
- var downloadResult = await _settingsFacade.Update.DownloadAssetAsync(
- archiveAsset,
- archivePath,
- downloadSource,
- downloadThreads,
- archiveProgress,
- cancellationToken);
-
- if (!downloadResult.Success)
- {
- downloadResult = await _settingsFacade.Update.RedownloadAssetAsync(
- archiveAsset,
- archivePath,
- downloadSource,
- downloadThreads,
- archiveProgress,
- cancellationToken);
- }
-
- if (!downloadResult.Success)
- {
- throw new PlondsDownloadException(
- "object-download",
- $"Failed to download PLONDS update archive: {downloadResult.ErrorMessage}");
- }
-
- try
- {
- if (Directory.Exists(objectsDirectory))
- {
- Directory.Delete(objectsDirectory, recursive: true);
- }
-
- Directory.CreateDirectory(objectsDirectory);
- ZipFile.ExtractToDirectory(archivePath, objectsDirectory, overwriteFiles: true);
- }
- catch (Exception ex)
- {
- throw new PlondsDownloadException(
- "payload-parse",
- $"Failed to extract PLONDS update archive: {ex.Message}",
- ex);
- }
- finally
- {
- DeleteFileIfExists(archivePath);
- }
-
- var objectResults = Directory.EnumerateFiles(objectsDirectory, "*", SearchOption.AllDirectories)
- .Select(path => new PlondsDownloadedObjectInfo(
- ComponentId: "app",
- RelativePath: Path.GetRelativePath(objectsDirectory, path).Replace('\\', '/'),
- SourceUrl: payload.UpdateArchiveUrl,
- ObjectHashHex: Path.GetFileName(path),
- LocalPath: path))
- .ToArray();
-
- progress?.Report(1d);
- return objectResults;
- }
-
- private static IReadOnlyList ParsePlondsDownloadEntries(string fileMapJson)
- {
- var entries = new List();
- if (string.IsNullOrWhiteSpace(fileMapJson))
- {
- return entries;
- }
-
- using var document = JsonDocument.Parse(fileMapJson);
- var root = document.RootElement;
- if (root.ValueKind != JsonValueKind.Object)
- {
- return entries;
- }
-
- if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode))
- {
- return entries;
- }
-
- if (componentsNode.ValueKind == JsonValueKind.Object)
- {
- foreach (var component in componentsNode.EnumerateObject())
- {
- if (component.Value.ValueKind != JsonValueKind.Object)
- {
- continue;
- }
-
- if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode))
- {
- continue;
- }
-
- AppendDownloadEntries(entries, component.Name, filesNode);
- }
- }
- else if (componentsNode.ValueKind == JsonValueKind.Array)
- {
- foreach (var component in componentsNode.EnumerateArray())
- {
- if (component.ValueKind != JsonValueKind.Object)
- {
- continue;
- }
-
- var componentId = ReadStringIgnoreCase(component, "id")
- ?? ReadStringIgnoreCase(component, "name")
- ?? "app";
- if (!TryGetPropertyIgnoreCase(component, "files", out var filesNode))
- {
- continue;
- }
-
- AppendDownloadEntries(entries, componentId, filesNode);
- }
- }
-
- return entries;
- }
-
- private static void AppendDownloadEntries(ICollection entries, string componentId, JsonElement filesNode)
- {
- if (filesNode.ValueKind == JsonValueKind.Object)
- {
- foreach (var fileEntry in filesNode.EnumerateObject())
- {
- if (fileEntry.Value.ValueKind != JsonValueKind.Object)
- {
- continue;
- }
-
- if (TryCreateDownloadEntry(componentId, fileEntry.Name, fileEntry.Value, out var entry))
- {
- entries.Add(entry);
- }
- }
-
- return;
- }
-
- if (filesNode.ValueKind != JsonValueKind.Array)
- {
- return;
- }
-
- foreach (var fileEntry in filesNode.EnumerateArray())
- {
- if (fileEntry.ValueKind != JsonValueKind.Object)
- {
- continue;
- }
-
- var relativePath = ReadStringIgnoreCase(fileEntry, "path");
- if (TryCreateDownloadEntry(componentId, relativePath, fileEntry, out var entry))
- {
- entries.Add(entry);
- }
- }
- }
-
- private static bool TryCreateDownloadEntry(
- string componentId,
- string? relativePath,
- JsonElement fileNode,
- out PlondsDownloadEntry entry)
- {
- entry = default!;
-
- var normalizedPath = string.IsNullOrWhiteSpace(relativePath)
- ? null
- : relativePath.Trim();
- var downloadUrl = ReadStringIgnoreCase(fileNode, "objecturl")
- ?? ReadStringIgnoreCase(fileNode, "downloadurl")
- ?? ReadStringIgnoreCase(fileNode, "archivedownloadurl")
- ?? ReadStringIgnoreCase(fileNode, "url");
- var hashHex = ReadStringIgnoreCase(fileNode, "sha256")
- ?? ReadStringIgnoreCase(fileNode, "filesha256")
- ?? ReadStringIgnoreCase(fileNode, "contenthash");
-
- if ((string.IsNullOrWhiteSpace(hashHex) || string.IsNullOrWhiteSpace(downloadUrl)) &&
- TryGetPropertyIgnoreCase(fileNode, "hash", out var hashNode) &&
- hashNode.ValueKind == JsonValueKind.Object)
- {
- var algorithm = ReadStringIgnoreCase(hashNode, "algorithm");
- if (string.IsNullOrWhiteSpace(algorithm) ||
- algorithm.Contains("sha256", StringComparison.OrdinalIgnoreCase))
- {
- hashHex ??= ReadStringIgnoreCase(hashNode, "value");
- }
- }
-
- if (string.IsNullOrWhiteSpace(normalizedPath) ||
- string.IsNullOrWhiteSpace(downloadUrl) ||
- string.IsNullOrWhiteSpace(hashHex))
- {
- return false;
- }
-
- entry = new PlondsDownloadEntry(
- componentId,
- normalizedPath,
- downloadUrl,
- NormalizeHashText(hashHex));
- return true;
- }
-
- private static async Task ComputeFileSha256HexAsync(string filePath, CancellationToken cancellationToken)
- {
- if (!File.Exists(filePath))
- {
- return null;
- }
-
- await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
- var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken);
- return Convert.ToHexString(hashBytes).ToLowerInvariant();
- }
-
- private static string NormalizeHashText(string hash)
- {
- var normalized = hash.Trim();
- var separator = normalized.IndexOf(':');
- if (separator >= 0 && separator < normalized.Length - 1)
- {
- normalized = normalized[(separator + 1)..];
- }
-
- return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
- }
-
- private static void DeleteFileIfExists(string path)
- {
- try
- {
- if (File.Exists(path))
- {
- File.Delete(path);
- }
- }
- catch
- {
- // Best effort cleanup only. The caller still verifies the resulting payload before it is applied.
- }
- }
-
- private static TimeSpan GetPlondsRetryDelay(int attempt)
- {
- return attempt switch
- {
- 1 => TimeSpan.FromMilliseconds(350),
- 2 => TimeSpan.FromMilliseconds(900),
- _ => TimeSpan.FromMilliseconds(1500)
- };
- }
-
- private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
- {
- if (node.ValueKind == JsonValueKind.Object)
- {
- foreach (var property in node.EnumerateObject())
- {
- if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
- {
- value = property.Value;
- return true;
- }
- }
- }
-
- value = default;
- return false;
- }
-
- private static string? ReadStringIgnoreCase(JsonElement node, string propertyName)
- {
- return TryGetPropertyIgnoreCase(node, propertyName, out var value)
- ? value.ValueKind == JsonValueKind.String
- ? value.GetString()
- : value.ToString()
- : null;
- }
-
- private static byte[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName)
- {
- if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
- {
- return null;
- }
-
- return ReadByteArray(value);
- }
-
- private static byte[]? ReadByteArray(JsonElement value)
- {
- switch (value.ValueKind)
- {
- case JsonValueKind.String:
- {
- var text = value.GetString()?.Trim();
- if (string.IsNullOrWhiteSpace(text))
- {
- return null;
- }
-
- if (IsHexString(text))
- {
- try
- {
- return Convert.FromHexString(text);
- }
- catch
- {
- // fall through to base64
- }
- }
-
- try
- {
- return Convert.FromBase64String(text);
- }
- catch
- {
- return null;
- }
- }
- case JsonValueKind.Array:
- {
- var bytes = new List();
- foreach (var item in value.EnumerateArray())
- {
- if (!item.TryGetInt32(out var number) || number is < byte.MinValue or > byte.MaxValue)
- {
- return null;
- }
-
- bytes.Add((byte)number);
- }
-
- return bytes.ToArray();
- }
- default:
- return null;
- }
- }
-
- private static bool IsHexString(string value)
- {
- if (string.IsNullOrWhiteSpace(value) || value.Length % 2 != 0)
- {
- return false;
- }
-
- foreach (var ch in value)
- {
- if (!Uri.IsHexDigit(ch))
- {
- return false;
- }
- }
-
- return true;
- }
-
- private sealed record PlondsDownloadEntry(
- string ComponentId,
- string RelativePath,
- string DownloadUrl,
- string ObjectHashHex);
-
- private sealed class PlondsDownloadException : Exception
- {
- public PlondsDownloadException(string stage, string message, Exception? innerException = null)
- : base(message, innerException)
- {
- Stage = stage;
- }
-
- public string Stage { get; }
- }
-
- private sealed record PlondsDownloadedObjectInfo(
- string ComponentId,
- string RelativePath,
- string SourceUrl,
- string ObjectHashHex,
- string LocalPath);
-
- private sealed record PlondsUpdateState(
- string VersionText,
- string DistributionId,
- string ChannelId,
- string SubChannel,
- string FileMapPath,
- string FileMapSignaturePath,
- string ObjectsDirectory,
- DateTimeOffset DownloadedAtUtc,
- string FileMapJson,
- string FileMapSignature,
- IReadOnlyList Objects);
-
- private static bool TryResolveDeltaAssets(
- IReadOnlyList assets,
- out GitHubReleaseAsset manifestAsset,
- out GitHubReleaseAsset signatureAsset,
- out GitHubReleaseAsset archiveAsset)
- {
- manifestAsset = default!;
- signatureAsset = default!;
- archiveAsset = default!;
-
- if (assets is null || assets.Count == 0)
- {
- return false;
- }
-
- var platformSuffix = GetPlatformAssetSuffix();
- var platformManifest = $"files-{platformSuffix}.json";
- var platformSignature = $"files-{platformSuffix}.json.sig";
- var platformArchive = $"update-{platformSuffix}.zip";
-
- var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName);
- var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName);
- var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName);
- if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null)
- {
- return false;
- }
-
- manifestAsset = manifestCandidate;
- signatureAsset = signatureCandidate;
- archiveAsset = archiveCandidate;
- return true;
- }
-
- private static GitHubReleaseAsset? FindAsset(IReadOnlyList assets, string name)
- {
- return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
- }
-
- private static string GetPlatformAssetSuffix()
- {
- var os = OperatingSystem.IsWindows()
- ? "windows"
- : OperatingSystem.IsLinux()
- ? "linux"
- : OperatingSystem.IsMacOS()
- ? "macos"
- : "unknown";
-
- var arch = RuntimeInformation.OSArchitecture switch
- {
- Architecture.X86 => "x86",
- Architecture.Arm => "arm",
- Architecture.Arm64 => "arm64",
- _ => "x64"
- };
-
- return $"{os}-{arch}";
- }
-
- public UpdatePendingInfo? GetPendingUpdate()
- {
- var state = _settingsFacade.Update.Get();
- return GetPendingUpdate(state);
- }
-
- public async Task CheckForUpdatesAsync(
- Version currentVersion,
- bool isForce = false,
- CancellationToken cancellationToken = default)
- {
- var state = _settingsFacade.Update.Get();
- var includePrerelease = string.Equals(
- UpdateSettingsValues.NormalizeChannel(state.UpdateChannel, state.IncludePrereleaseUpdates),
- UpdateSettingsValues.ChannelPreview,
- StringComparison.OrdinalIgnoreCase);
-
- var result = isForce
- ? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
- currentVersion,
- includePrerelease,
- cancellationToken)
- : await _settingsFacade.Update.CheckForUpdatesAsync(
- currentVersion,
- includePrerelease,
- cancellationToken);
-
- SaveState(state with
- {
- LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
- });
-
- return result;
- }
-
- public async Task ForceCheckForUpdatesAsync(
- Version currentVersion,
- CancellationToken cancellationToken = default)
- {
- return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
- }
-
- public async Task DownloadReleaseAsync(
- UpdateCheckResult checkResult,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- ArgumentNullException.ThrowIfNull(checkResult);
-
- if (checkResult.PlondsPayload is not null)
- {
- return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
- }
-
- return await DownloadFullInstallerAsync(
- checkResult,
- progress,
- cancellationToken,
- forceRedownload: false);
- }
-
- public async Task RedownloadReleaseAsync(
- UpdateCheckResult checkResult,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- ArgumentNullException.ThrowIfNull(checkResult);
-
- if (checkResult.PlondsPayload is not null)
- {
- ClearPendingUpdate();
- return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
- }
-
- return await DownloadFullInstallerAsync(
- checkResult,
- progress,
- cancellationToken,
- forceRedownload: true);
- }
-
- public async Task VerifyPendingUpdateAsync()
- {
- var state = _settingsFacade.Update.Get();
- var pending = GetPendingUpdate(state);
-
- if (pending is null)
- {
- return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
- }
-
- if (!File.Exists(pending.InstallerPath))
- {
- if (IsPendingDeltaUpdate())
- {
- var pdcUpdatePath = pending.InstallerPath;
- var pdcFileMapPath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapName);
- var pdcSignaturePath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapSignatureName);
- if (File.Exists(pdcUpdatePath) && File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
- {
- return new UpdateVerifyResult(true, true, null, null, null);
- }
-
- return new UpdateVerifyResult(false, false, null, null, "PLONDS update payload is incomplete.");
- }
-
- return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
- }
-
- if (IsPendingDeltaUpdate())
- {
- return new UpdateVerifyResult(true, true, null, null, null);
- }
-
- var expectedHash = pending.Sha256;
- var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
-
- if (string.IsNullOrEmpty(expectedHash))
- {
- return new UpdateVerifyResult(true, true, null, actualHash, null);
- }
-
- var hashMatched = string.Equals(
- expectedHash?.Trim().ToLowerInvariant(),
- actualHash?.Trim().ToLowerInvariant(),
- StringComparison.OrdinalIgnoreCase);
-
- return new UpdateVerifyResult(
- hashMatched,
- hashMatched,
- expectedHash,
- actualHash,
- hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
- }
-
- public async Task AutoCheckIfEnabledAsync(
- Version currentVersion,
- CancellationToken cancellationToken = default)
- {
- var state = _settingsFacade.Update.Get();
-
- try
- {
- // Always check for updates on startup (removed AutoCheckUpdates check)
- var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
- if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PlondsPayload is null))
- {
- return;
- }
-
- var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
-
- // For "Silent Download" and "Silent Install" modes, automatically download the update
- if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
- string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
- {
- // Prefer delta update if available (smaller download, faster)
- if (IsDeltaUpdateAvailable(result))
- {
- AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package.");
- await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken);
- }
- else if (result.PreferredAsset is not null)
- {
- await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
- }
- }
- // For "Manual" mode, just check but don't download
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception ex)
- {
- AppLogger.Warn("UpdateWorkflow", "Automatic update check failed.", ex);
- }
- }
-
- public UpdateInstallerLaunchResult LaunchPendingInstallerNow()
- {
- if (IsPendingDeltaUpdate())
- {
- var launchResult = LaunchLauncherForApplyUpdate();
- return launchResult
- ? new UpdateInstallerLaunchResult(true, false, null)
- : new UpdateInstallerLaunchResult(false, false, "Failed to launch updater for incremental update.");
- }
-
- return LaunchPendingInstaller(silent: false, exitApplicationAfterLaunch: true);
- }
-
- public bool TryApplyPendingUpdateOnExit()
- {
- var state = _settingsFacade.Update.Get();
- if (!string.Equals(
- UpdateSettingsValues.NormalizeMode(state.UpdateMode),
- UpdateSettingsValues.ModeSilentOnExit,
- StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- // For delta updates, launch the Launcher with apply-update command so it can
- // apply the update immediately with a progress UI, matching the full installer experience.
- if (IsPendingDeltaUpdate())
- {
- AppLogger.Info("UpdateWorkflow", "Delta update pending. Launching Launcher to apply update with progress UI.");
- var launchResult = LaunchLauncherForApplyUpdate();
- if (launchResult)
- {
- ClearPendingUpdate();
- }
- return launchResult;
- }
-
- var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false);
- if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage))
- {
- AppLogger.Warn("UpdateWorkflow", $"Silent update on exit failed: {result.ErrorMessage}");
- }
-
- return result.Success;
- }
-
- ///
- /// Launches the Launcher process with the apply-update command to apply a pending delta update
- /// with a progress UI, providing an experience similar to a full installer.
- ///
- public bool LaunchLauncherForApplyUpdate()
- {
- try
- {
- var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
- if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
- {
- AppLogger.Warn("UpdateWorkflow", "Launcher executable not found. Falling back to next-startup apply.");
- return false;
- }
-
- var launcherRoot = Path.GetDirectoryName(launcherPath)!;
-
- var startInfo = new ProcessStartInfo
- {
- FileName = launcherPath,
- Arguments = $"apply-update --app-root \"{launcherRoot}\" --launch-source apply-update",
- UseShellExecute = false,
- WorkingDirectory = launcherRoot
- };
-
- Process.Start(startInfo);
- AppLogger.Info("UpdateWorkflow", $"Launched Launcher for apply-update: {launcherPath}");
- return true;
- }
- catch (Exception ex)
- {
- AppLogger.Warn("UpdateWorkflow", $"Failed to launch Launcher for apply-update: {ex.Message}");
- return false;
- }
- }
-
- public void ClearPendingUpdate()
- {
- var state = _settingsFacade.Update.Get();
- SaveState(state with
- {
- PendingUpdateInstallerPath = null,
- PendingUpdateVersion = null,
- PendingUpdatePublishedAtUtcMs = null,
- PendingUpdateSha256 = null
- });
- }
-
- private UpdateInstallerLaunchResult LaunchPendingInstaller(bool silent, bool exitApplicationAfterLaunch)
- {
- var state = _settingsFacade.Update.Get();
- var pending = GetPendingUpdate(state);
- if (pending is null)
- {
- return new UpdateInstallerLaunchResult(false, false, "No pending installer is available.");
- }
-
- try
- {
- AppLogger.Info("UpdateWorkflow", "Launching pending full installer with elevation reason 'full_update_apply'.");
- var startInfo = new ProcessStartInfo
- {
- FileName = pending.InstallerPath,
- WorkingDirectory = Path.GetDirectoryName(pending.InstallerPath) ?? _updatesDirectory,
- UseShellExecute = true,
- Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty,
- Arguments = silent ? "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" : string.Empty
- };
-
- Process.Start(startInfo);
- ClearPendingUpdate();
-
- if (exitApplicationAfterLaunch)
- {
- App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
- Source: "Update",
- Reason: silent
- ? "Silent installer launched."
- : "Installer launched from update page."));
- }
-
- return new UpdateInstallerLaunchResult(true, false, null);
- }
- catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
- {
- return new UpdateInstallerLaunchResult(false, true, ex.Message);
- }
- catch (Exception ex)
- {
- return new UpdateInstallerLaunchResult(false, false, ex.Message);
- }
- }
-
- private UpdatePendingInfo? GetPendingUpdate(UpdateSettingsState state)
- {
- var installerPath = state.PendingUpdateInstallerPath?.Trim();
- if (string.IsNullOrWhiteSpace(installerPath))
- {
- return null;
- }
-
- if (!File.Exists(installerPath))
- {
- ClearPendingUpdate();
- return null;
- }
-
- DateTimeOffset? publishedAt = state.PendingUpdatePublishedAtUtcMs is > 0
- ? DateTimeOffset.FromUnixTimeMilliseconds(state.PendingUpdatePublishedAtUtcMs.Value)
- : null;
-
- return new UpdatePendingInfo(
- installerPath,
- string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
- publishedAt,
- state.PendingUpdateSha256);
- }
-
- private void SaveState(UpdateSettingsState state)
- {
- _settingsFacade.Update.Save(state);
- }
-
- private static string SanitizeFileName(string? fileName)
- {
- if (string.IsNullOrWhiteSpace(fileName))
- {
- return FormattableString.Invariant($"LanMountainDesktop-update-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.exe");
- }
-
- var invalid = Path.GetInvalidFileNameChars();
- Span buffer = stackalloc char[fileName.Length];
- var index = 0;
- foreach (var ch in fileName)
- {
- buffer[index++] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch;
- }
-
- return new string(buffer[..index]);
- }
-}
diff --git a/LanMountainDesktop/Services/WallpaperColorPipeline.cs b/LanMountainDesktop/Services/WallpaperColorPipeline.cs
new file mode 100644
index 0000000..08df2eb
--- /dev/null
+++ b/LanMountainDesktop/Services/WallpaperColorPipeline.cs
@@ -0,0 +1,353 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.Services.Settings;
+
+namespace LanMountainDesktop.Services;
+
+internal readonly record struct WallpaperSeedSourceDescriptor(
+ string SourceKind,
+ string SourceKey,
+ string? ResolvedWallpaperPath,
+ string? FilePath,
+ Color? SolidColor);
+
+internal sealed record WallpaperSeedExtractionResult(
+ string SourceKind,
+ string SourceKey,
+ string? ResolvedWallpaperPath,
+ IReadOnlyList SeedCandidates);
+
+internal readonly record struct WallpaperPaletteResolution(
+ MonetPalette Palette,
+ IReadOnlyList SeedCandidates,
+ string ResolvedSeedSource,
+ Color EffectiveSeedColor,
+ string? ResolvedWallpaperPath);
+
+internal sealed class WallpaperColorPipeline
+{
+ private static readonly Color NeutralFallbackSeedColor = Color.Parse("#FF8A8A8A");
+
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly ISystemWallpaperProvider _systemWallpaperProvider;
+ private readonly MonetColorService _monetColorService;
+ private readonly Action _notifyChanged;
+ private readonly object _gate = new();
+ private readonly Dictionary _seedCache = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _pendingSeedKeys = new(StringComparer.OrdinalIgnoreCase);
+
+ public WallpaperColorPipeline(
+ ISettingsFacadeService settingsFacade,
+ ISystemWallpaperProvider systemWallpaperProvider,
+ MonetColorService monetColorService,
+ Action notifyChanged)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _systemWallpaperProvider = systemWallpaperProvider ?? throw new ArgumentNullException(nameof(systemWallpaperProvider));
+ _monetColorService = monetColorService ?? throw new ArgumentNullException(nameof(monetColorService));
+ _notifyChanged = notifyChanged ?? throw new ArgumentNullException(nameof(notifyChanged));
+ }
+
+ public void Clear()
+ {
+ lock (_gate)
+ {
+ _seedCache.Clear();
+ _pendingSeedKeys.Clear();
+ }
+ }
+
+ public WallpaperPaletteResolution Resolve(
+ bool nightMode,
+ WallpaperSettingsState wallpaperState,
+ string wallpaperColorSource,
+ string? selectedWallpaperSeed,
+ bool queueWallpaperPaletteBuild)
+ {
+ var source = ResolveSource(wallpaperState, wallpaperColorSource);
+ if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase))
+ {
+ return BuildFallbackResolution(nightMode, source.ResolvedWallpaperPath);
+ }
+
+ if (string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase))
+ {
+ var candidates = source.SolidColor is { } solidColor
+ ? new[] { solidColor }
+ : [];
+ return BuildResolution(nightMode, source, candidates, selectedWallpaperSeed);
+ }
+
+ lock (_gate)
+ {
+ if (_seedCache.TryGetValue(source.SourceKey, out var cachedSeedResult))
+ {
+ if (cachedSeedResult.SeedCandidates.Count > 0)
+ {
+ return BuildResolution(
+ nightMode,
+ source with
+ {
+ SourceKind = cachedSeedResult.SourceKind,
+ ResolvedWallpaperPath = cachedSeedResult.ResolvedWallpaperPath
+ },
+ cachedSeedResult.SeedCandidates,
+ selectedWallpaperSeed);
+ }
+
+ return BuildFallbackResolution(nightMode, cachedSeedResult.ResolvedWallpaperPath);
+ }
+ }
+
+ if (queueWallpaperPaletteBuild)
+ {
+ QueueSeedExtraction(source);
+ }
+
+ return BuildFallbackResolution(nightMode, source.ResolvedWallpaperPath);
+ }
+
+ public WallpaperSeedSourceDescriptor ResolveSource(
+ WallpaperSettingsState wallpaperState,
+ string wallpaperColorSource)
+ {
+ var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(wallpaperColorSource);
+
+ if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem &&
+ string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
+ !string.IsNullOrWhiteSpace(wallpaperState.Color) &&
+ Color.TryParse(wallpaperState.Color, out var solidColor))
+ {
+ var solidText = solidColor.ToString();
+ return new WallpaperSeedSourceDescriptor(
+ "app_solid",
+ $"app_solid|{solidText}",
+ null,
+ null,
+ solidColor);
+ }
+
+ var wallpaperPath = string.IsNullOrWhiteSpace(wallpaperState.WallpaperPath)
+ ? null
+ : wallpaperState.WallpaperPath.Trim();
+ var appWallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperPath);
+ if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem &&
+ !string.IsNullOrWhiteSpace(wallpaperPath) &&
+ File.Exists(wallpaperPath) &&
+ appWallpaperMediaType == WallpaperMediaType.Image)
+ {
+ return new WallpaperSeedSourceDescriptor(
+ "app_wallpaper",
+ CreateWallpaperSourceKey("app_wallpaper", wallpaperPath),
+ wallpaperPath,
+ wallpaperPath,
+ null);
+ }
+
+ if (normalizedWallpaperColorSource == ThemeAppearanceValues.WallpaperColorSourceApp)
+ {
+ return new WallpaperSeedSourceDescriptor(
+ "fallback",
+ "fallback",
+ null,
+ null,
+ null);
+ }
+
+ var systemWallpaper = _systemWallpaperProvider.GetWallpaperPath();
+ if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceApp &&
+ !string.IsNullOrWhiteSpace(systemWallpaper) &&
+ File.Exists(systemWallpaper) &&
+ _settingsFacade.WallpaperMedia.DetectMediaType(systemWallpaper) == WallpaperMediaType.Image)
+ {
+ return new WallpaperSeedSourceDescriptor(
+ "system_wallpaper",
+ CreateWallpaperSourceKey("system_wallpaper", systemWallpaper),
+ systemWallpaper,
+ systemWallpaper,
+ null);
+ }
+
+ return new WallpaperSeedSourceDescriptor(
+ "fallback",
+ "fallback",
+ null,
+ null,
+ null);
+ }
+
+ private void QueueSeedExtraction(WallpaperSeedSourceDescriptor source)
+ {
+ if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ if (_pendingSeedKeys.Contains(source.SourceKey))
+ {
+ return;
+ }
+
+ _pendingSeedKeys.Add(source.SourceKey);
+ }
+
+ _ = Task.Run(() =>
+ {
+ WallpaperSeedExtractionResult? extractionResult = null;
+
+ try
+ {
+ extractionResult = ExtractSeedCandidates(source);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ "Appearance.WallpaperSeed",
+ $"Failed to build wallpaper seed candidates asynchronously. Source='{source.SourceKind}'; Path='{source.FilePath}'.",
+ ex);
+ }
+ finally
+ {
+ lock (_gate)
+ {
+ _pendingSeedKeys.Remove(source.SourceKey);
+ if (extractionResult is not null)
+ {
+ _seedCache[source.SourceKey] = extractionResult;
+ }
+ }
+ }
+
+ if (extractionResult is not null)
+ {
+ _notifyChanged(false);
+ }
+ });
+ }
+
+ private WallpaperSeedExtractionResult ExtractSeedCandidates(WallpaperSeedSourceDescriptor source)
+ {
+ IReadOnlyList seedCandidates = source.SourceKind switch
+ {
+ "app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
+ "app_solid" when source.SolidColor is { } solidColor => new[] { solidColor },
+ _ => []
+ };
+
+ return new WallpaperSeedExtractionResult(
+ source.SourceKind,
+ source.SourceKey,
+ source.ResolvedWallpaperPath,
+ seedCandidates);
+ }
+
+ private IReadOnlyList ExtractImageSeedCandidates(string? wallpaperPath)
+ {
+ if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath))
+ {
+ return [];
+ }
+
+ try
+ {
+ using var bitmap = new Bitmap(wallpaperPath);
+ return _monetColorService.ExtractSeedCandidates(bitmap);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ "Appearance.WallpaperSeed",
+ $"Failed to extract wallpaper seed candidates from image '{wallpaperPath}'.",
+ ex);
+ return [];
+ }
+ }
+
+ private WallpaperPaletteResolution BuildResolution(
+ bool nightMode,
+ WallpaperSeedSourceDescriptor source,
+ IReadOnlyList seedCandidates,
+ string? selectedWallpaperSeed)
+ {
+ var validatedSeed = ResolveSelectedWallpaperSeed(seedCandidates, selectedWallpaperSeed);
+ var palette = _monetColorService.BuildPaletteFromSeedCandidates(seedCandidates, nightMode, validatedSeed);
+ return new WallpaperPaletteResolution(
+ palette,
+ seedCandidates,
+ source.SourceKind,
+ palette.Seed,
+ source.ResolvedWallpaperPath);
+ }
+
+ private WallpaperPaletteResolution BuildFallbackResolution(bool nightMode, string? resolvedWallpaperPath)
+ {
+ var palette = _monetColorService.BuildPaletteFromSeedCandidates([], nightMode, NeutralFallbackSeedColor);
+ return new WallpaperPaletteResolution(
+ palette,
+ [],
+ "fallback",
+ palette.Seed,
+ resolvedWallpaperPath);
+ }
+
+ private static Color? ResolveSelectedWallpaperSeed(
+ IReadOnlyList seedCandidates,
+ string? selectedWallpaperSeed)
+ {
+ if (seedCandidates.Count == 0)
+ {
+ return null;
+ }
+
+ if (!string.IsNullOrWhiteSpace(selectedWallpaperSeed) &&
+ Color.TryParse(selectedWallpaperSeed, out var parsedSeed))
+ {
+ foreach (var candidate in seedCandidates)
+ {
+ if (candidate == parsedSeed)
+ {
+ return candidate;
+ }
+ }
+ }
+
+ return seedCandidates[0];
+ }
+
+ private static string CreateWallpaperSourceKey(string sourceKind, string wallpaperPath)
+ {
+ long lastWriteTicks = 0;
+ long length = 0;
+
+ try
+ {
+ var fileInfo = new FileInfo(wallpaperPath);
+ if (fileInfo.Exists)
+ {
+ lastWriteTicks = fileInfo.LastWriteTimeUtc.Ticks;
+ length = fileInfo.Length;
+ }
+ }
+ catch
+ {
+ // Keep the cache key resilient even if metadata lookup fails.
+ }
+
+ return string.Concat(
+ sourceKind,
+ "|",
+ wallpaperPath,
+ "|",
+ lastWriteTicks.ToString(),
+ "|",
+ length.ToString());
+ }
+}
diff --git a/LanMountainDesktop/Services/WeatherIconAssetResolver.cs b/LanMountainDesktop/Services/WeatherIconAssetResolver.cs
new file mode 100644
index 0000000..f8e986b
--- /dev/null
+++ b/LanMountainDesktop/Services/WeatherIconAssetResolver.cs
@@ -0,0 +1,235 @@
+using System;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using LanMountainDesktop.Models;
+
+namespace LanMountainDesktop.Services;
+
+public static class WeatherIconAssetResolver
+{
+ private const string RootUri = "avares://LanMountainDesktop/Assets/MaterialWeatherIcons";
+
+ public static Bitmap? LoadIcon(string? styleId, WeatherSnapshot? snapshot)
+ {
+ return LoadIcon(styleId, ResolveIconKey(snapshot));
+ }
+
+ public static Bitmap? LoadIcon(string? styleId, int? weatherCode, string? weatherText, bool isDaylight = true)
+ {
+ return LoadIcon(styleId, ResolveIconKey(weatherCode, weatherText, isDaylight));
+ }
+
+ public static Bitmap? LoadIcon(string? styleId, string iconKey)
+ {
+ var uri = ResolveAssetUri(styleId, iconKey);
+ if (uri is null)
+ {
+ return null;
+ }
+
+ using var stream = AssetLoader.Open(uri);
+ return new Bitmap(stream);
+ }
+
+ public static Uri? ResolveAssetUri(string? styleId, WeatherSnapshot? snapshot)
+ {
+ return ResolveAssetUri(styleId, ResolveIconKey(snapshot));
+ }
+
+ public static Uri? ResolveAssetUri(string? styleId, int? weatherCode, string? weatherText, bool isDaylight = true)
+ {
+ return ResolveAssetUri(styleId, ResolveIconKey(weatherCode, weatherText, isDaylight));
+ }
+
+ public static Uri? ResolveAssetUri(string? styleId, string iconKey)
+ {
+ var style = WeatherVisualStyleCatalog.GetStyle(styleId);
+ return TryBuildUri(style, iconKey)
+ ?? TryBuildUri(style, NormalizeDayNightFallback(iconKey))
+ ?? TryBuildUri(style, "cloudy_day")
+ ?? TryBuildUri(WeatherVisualStyleCatalog.GetDefault(), iconKey)
+ ?? TryBuildUri(WeatherVisualStyleCatalog.GetDefault(), NormalizeDayNightFallback(iconKey))
+ ?? TryBuildUri(WeatherVisualStyleCatalog.GetDefault(), "cloudy_day");
+ }
+
+ public static string ResolveIconKey(WeatherSnapshot? snapshot)
+ {
+ var current = snapshot?.Current;
+ var isDaylight = current?.IsDaylight ?? true;
+ return ResolveIconKey(current?.WeatherCode, current?.WeatherText, isDaylight);
+ }
+
+ public static string ResolveIconKey(int? weatherCode, string? weatherText, bool isDaylight)
+ {
+ var dayNight = isDaylight ? "day" : "night";
+ var condition = ResolveCondition(weatherCode, weatherText);
+ return condition switch
+ {
+ "clear" => $"clear_{dayNight}",
+ "partly_cloudy" => $"partly_cloudy_{dayNight}",
+ "cloudy" => $"cloudy_{dayNight}",
+ "rain" => $"rain_{dayNight}",
+ "sleet" => $"sleet_{dayNight}",
+ "snow" => $"snow_{dayNight}",
+ "hail" => $"hail_{dayNight}",
+ "thunder" => $"thunder_{dayNight}",
+ "thunderstorm" => $"thunderstorm_{dayNight}",
+ "fog" => $"fog_{dayNight}",
+ "haze" => $"haze_{dayNight}",
+ "wind" => $"wind_{dayNight}",
+ _ => $"cloudy_{dayNight}"
+ };
+ }
+
+ private static Uri? TryBuildUri(WeatherVisualStyleDefinition style, string iconKey)
+ {
+ var fileName = ResolveFileName(style.Id, iconKey);
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return null;
+ }
+
+ var uri = new Uri($"{RootUri}/{style.AssetFolder}/{fileName}", UriKind.Absolute);
+ try
+ {
+ return AssetLoader.Exists(uri) ? uri : null;
+ }
+ catch (InvalidOperationException)
+ {
+ return uri;
+ }
+ }
+
+ private static string ResolveFileName(string styleId, string iconKey)
+ {
+ var normalized = NormalizeDayNightFallback(iconKey);
+ return styleId switch
+ {
+ WeatherVisualStyleId.GoogleWeatherV4 => ResolveGoogleFileName(iconKey),
+ WeatherVisualStyleId.Geometric => ResolveGeometricFileName(normalized),
+ WeatherVisualStyleId.Breezy => ResolveBreezyFileName(normalized),
+ WeatherVisualStyleId.LemonFlutter => ResolveLemonFileName(normalized),
+ _ => ResolveGoogleFileName(iconKey)
+ };
+ }
+
+ private static string ResolveGoogleFileName(string iconKey)
+ {
+ return iconKey switch
+ {
+ "cloudy_day" => "weather_cloudy_day.png",
+ "cloudy_night" => "weather_cloudy_night.png",
+ _ => $"weather_{iconKey}.png"
+ };
+ }
+
+ private static string ResolveGeometricFileName(string iconKey)
+ {
+ return iconKey switch
+ {
+ "clear_day" or "clear_night" or "partly_cloudy_day" or "partly_cloudy_night" => $"weather_{iconKey}_geometric.png",
+ "cloudy_day" or "cloudy_night" => "weather_cloudy_geometric.png",
+ "rain_day" or "rain_night" => "weather_rain_geometric.png",
+ "sleet_day" or "sleet_night" => "weather_sleet_geometric.png",
+ "snow_day" or "snow_night" => "weather_snow_geometric.png",
+ "hail_day" or "hail_night" => "weather_hail_geometric.png",
+ "fog_day" or "fog_night" => "weather_fog_geometric.png",
+ "haze_day" or "haze_night" => "weather_haze_geometric.png",
+ "wind_day" or "wind_night" => "weather_wind_geometric.png",
+ "thunderstorm_day" or "thunderstorm_night" => "weather_thunder_geometric.png",
+ "thunder_day" or "thunder_night" => "weather_thunder_geometric.png",
+ _ => $"weather_{iconKey}_geometric.png"
+ };
+ }
+
+ private static string ResolveBreezyFileName(string iconKey)
+ {
+ return iconKey switch
+ {
+ "clear_day" or "clear_night" or "partly_cloudy_day" or "partly_cloudy_night" => $"weather_{iconKey}.png",
+ "cloudy_day" or "cloudy_night" => "weather_cloudy.png",
+ "rain_day" or "rain_night" => "weather_rain.png",
+ "sleet_day" or "sleet_night" => "weather_sleet.png",
+ "snow_day" or "snow_night" => "weather_snow.png",
+ "hail_day" or "hail_night" => "weather_hail.png",
+ "fog_day" or "fog_night" => "weather_fog.png",
+ "haze_day" or "haze_night" => "weather_haze.png",
+ "wind_day" or "wind_night" => "weather_wind.png",
+ "thunder_day" or "thunder_night" => "weather_thunder.png",
+ "thunderstorm_day" or "thunderstorm_night" => "weather_thunderstorm.png",
+ _ => $"weather_{iconKey}.png"
+ };
+ }
+
+ private static string ResolveLemonFileName(string iconKey)
+ {
+ return iconKey switch
+ {
+ "clear_day" or "clear_night" => "ic_sun.png",
+ "partly_cloudy_day" or "partly_cloudy_night" => "ic_cloudy.png",
+ "cloudy_day" or "cloudy_night" => "ic_cloud.png",
+ "rain_day" or "rain_night" => "ic_rain.png",
+ "sleet_day" or "sleet_night" => "ic_light_rain.png",
+ "snow_day" or "snow_night" => "ic_snow.png",
+ "hail_day" or "hail_night" => "ic_storm.png",
+ "thunder_day" or "thunder_night" => "ic_thunder.png",
+ "thunderstorm_day" or "thunderstorm_night" => "ic_storm.png",
+ "fog_day" or "fog_night" => "ic_cloudy.png",
+ "haze_day" or "haze_night" => "ic_cloudy.png",
+ "wind_day" or "wind_night" => "ic_windmill.png",
+ _ => "ic_cloud.png"
+ };
+ }
+
+ private static string NormalizeDayNightFallback(string iconKey)
+ {
+ return iconKey switch
+ {
+ "cloudy_night" => "cloudy_day",
+ "rain_night" => "rain_day",
+ "sleet_night" => "sleet_day",
+ "snow_night" => "snow_day",
+ "hail_night" => "hail_day",
+ "thunder_night" => "thunder_day",
+ "thunderstorm_night" => "thunderstorm_day",
+ "fog_night" => "fog_day",
+ "haze_night" => "haze_day",
+ "wind_night" => "wind_day",
+ _ => iconKey
+ };
+ }
+
+ private static string ResolveCondition(int? weatherCode, string? weatherText)
+ {
+ if (weatherCode.HasValue)
+ {
+ return weatherCode.Value switch
+ {
+ 0 => "clear",
+ 1 => "partly_cloudy",
+ 2 => "cloudy",
+ 3 or 7 or 8 or 9 or 10 or 11 or 12 or 19 or 21 or 22 or 23 or 24 or 25 or 301 => "rain",
+ 4 or 5 => "thunderstorm",
+ 6 or 13 or 14 or 15 or 16 or 17 or 26 or 27 or 28 or 302 => "snow",
+ 18 or 32 or 49 or 57 or 58 => "fog",
+ 20 or 29 or 30 or 31 or 53 or 54 or 55 or 56 => "haze",
+ _ => "cloudy"
+ };
+ }
+
+ var text = weatherText?.Trim().ToLowerInvariant() ?? string.Empty;
+ if (text.Contains("thunderstorm") || text.Contains('\u96f7')) return "thunderstorm";
+ if (text.Contains("thunder") || text.Contains("storm")) return "thunder";
+ if (text.Contains("sleet")) return "sleet";
+ if (text.Contains("hail")) return "hail";
+ if (text.Contains("snow") || text.Contains('\u96ea')) return "snow";
+ if (text.Contains("rain") || text.Contains('\u96e8')) return "rain";
+ if (text.Contains("fog") || text.Contains("mist") || text.Contains('\u96fe')) return "fog";
+ if (text.Contains("haze") || text.Contains("dust") || text.Contains('\u973e')) return "haze";
+ if (text.Contains("wind")) return "wind";
+ if (text.Contains("partly")) return "partly_cloudy";
+ if (text.Contains("cloud") || text.Contains('\u4e91') || text.Contains('\u9634')) return "cloudy";
+ if (text.Contains("clear") || text.Contains("sun") || text.Contains('\u6674')) return "clear";
+ return "cloudy";
+ }
+}
diff --git a/LanMountainDesktop/Services/WeatherLocationRefreshService.cs b/LanMountainDesktop/Services/WeatherLocationRefreshService.cs
index 3830e92..e289ead 100644
--- a/LanMountainDesktop/Services/WeatherLocationRefreshService.cs
+++ b/LanMountainDesktop/Services/WeatherLocationRefreshService.cs
@@ -125,9 +125,7 @@ public sealed class WeatherLocationRefreshService
private static string NormalizeIconPackId(string? iconPackId)
{
- return string.IsNullOrWhiteSpace(iconPackId)
- ? "HyperOS3"
- : "HyperOS3";
+ return WeatherVisualStyleCatalog.Normalize(iconPackId);
}
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)
diff --git a/LanMountainDesktop/Services/WeatherVisualStyleCatalog.cs b/LanMountainDesktop/Services/WeatherVisualStyleCatalog.cs
new file mode 100644
index 0000000..c0d089d
--- /dev/null
+++ b/LanMountainDesktop/Services/WeatherVisualStyleCatalog.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LanMountainDesktop.Services;
+
+public static class WeatherVisualStyleId
+{
+ public const string GoogleWeatherV4 = "GoogleWeatherV4";
+ public const string Geometric = "Geometric";
+ public const string Breezy = "Breezy";
+ public const string LemonFlutter = "LemonFlutter";
+
+ public const string Default = GoogleWeatherV4;
+}
+
+public sealed record WeatherVisualStyleDefinition(
+ string Id,
+ string DisplayName,
+ string AssetFolder,
+ string SourceDescription);
+
+public static class WeatherVisualStyleCatalog
+{
+ private static readonly WeatherVisualStyleDefinition[] Styles =
+ [
+ new(
+ WeatherVisualStyleId.GoogleWeatherV4,
+ "Google Weather v4",
+ "google-weather-v4",
+ "Google Weather Icons v4 pack for Breezy Weather; icon licensing is uncertain."),
+ new(
+ WeatherVisualStyleId.Geometric,
+ "Geometric",
+ "geometric",
+ "Geometric Weather icon provider, compatible with Breezy Weather."),
+ new(
+ WeatherVisualStyleId.Breezy,
+ "Breezy Weather",
+ "breezy",
+ "Breezy Weather bundled icon resources."),
+ new(
+ WeatherVisualStyleId.LemonFlutter,
+ "Lemon Weather Flutter",
+ "lemon-flutter",
+ "spica_weather_flutter assets, MIT licensed.")
+ ];
+
+ public static IReadOnlyList GetStyles() => Styles;
+
+ public static WeatherVisualStyleDefinition GetDefault() => Styles[0];
+
+ public static WeatherVisualStyleDefinition GetStyle(string? id)
+ {
+ var normalized = Normalize(id);
+ return Styles.First(style => string.Equals(style.Id, normalized, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public static string Normalize(string? id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ return WeatherVisualStyleId.Default;
+ }
+
+ var candidate = id.Trim();
+ if (string.Equals(candidate, "DefaultWeather", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(candidate, "HyperOS3", StringComparison.OrdinalIgnoreCase))
+ {
+ return WeatherVisualStyleId.Default;
+ }
+
+ return Styles.Any(style => string.Equals(style.Id, candidate, StringComparison.OrdinalIgnoreCase))
+ ? Styles.First(style => string.Equals(style.Id, candidate, StringComparison.OrdinalIgnoreCase)).Id
+ : WeatherVisualStyleId.Default;
+ }
+}
diff --git a/LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs b/LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
index efdafdc..e68f9d3 100644
--- a/LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
+++ b/LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
@@ -1,4 +1,7 @@
using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
@@ -8,18 +11,39 @@ namespace LanMountainDesktop.Services;
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
{
private const int DefaultCleanupBatchSize = 256;
+ private const int CurrentSnapshotVersion = 2;
+ private const string Category = "WhiteboardPersistence";
+
private static readonly JsonSerializerOptions JsonOptions = new()
{
- PropertyNameCaseInsensitive = true
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true
};
- private readonly object _schemaSyncRoot = new();
- private readonly AppDatabaseService _databaseService;
- private bool _schemaInitialized;
+ private readonly object _legacySchemaSyncRoot = new();
+ private readonly string _whiteboardsRootDirectory;
+ private readonly AppDatabaseService _legacyDatabaseService;
+ private bool _legacySchemaInitialized;
- public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
+ public WhiteboardNotePersistenceService()
+ : this(Path.Combine(AppDataPathProvider.GetDataRoot(), "Whiteboards"), AppDatabaseServiceFactory.CreateDefault())
{
- _databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
+ }
+
+ public WhiteboardNotePersistenceService(AppDatabaseService? legacyDatabaseService)
+ : this(Path.Combine(AppDataPathProvider.GetDataRoot(), "Whiteboards"), legacyDatabaseService)
+ {
+ }
+
+ public WhiteboardNotePersistenceService(string whiteboardsRootDirectory, AppDatabaseService? legacyDatabaseService = null)
+ {
+ if (string.IsNullOrWhiteSpace(whiteboardsRootDirectory))
+ {
+ throw new ArgumentException("Whiteboard root directory cannot be null or whitespace.", nameof(whiteboardsRootDirectory));
+ }
+
+ _whiteboardsRootDirectory = Path.GetFullPath(whiteboardsRootDirectory);
+ _legacyDatabaseService = legacyDatabaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
@@ -29,108 +53,89 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
return new WhiteboardNoteSnapshot();
}
+ var notePath = GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
+ var normalizedRetentionDays = WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays);
+
try
{
- using var connection = OpenConnection();
- DeleteExpiredInternal(
- connection,
- normalizedComponentId,
- normalizedPlacementId,
- WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
- DateTimeOffset.UtcNow);
+ if (File.Exists(notePath))
+ {
+ var snapshot = ReadSnapshot(notePath);
+ if (IsExpired(snapshot, normalizedRetentionDays))
+ {
+ TryDeleteFile(notePath);
+ return new WhiteboardNoteSnapshot();
+ }
- using var command = connection.CreateCommand();
- command.CommandText = """
- SELECT note_json, saved_at_utc_ms
- FROM whiteboard_notes
- WHERE component_id = $componentId
- AND placement_id = $placementId
- LIMIT 1;
- """;
- command.Parameters.AddWithValue("$componentId", normalizedComponentId);
- command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
+ return snapshot.Clone();
+ }
- using var reader = command.ExecuteReader();
- if (!reader.Read() || reader.IsDBNull(0))
+ var legacySnapshot = TryLoadLegacyNote(normalizedComponentId, normalizedPlacementId, normalizedRetentionDays);
+ if (legacySnapshot.Strokes.Count == 0 && legacySnapshot.SavedUtc == default)
{
return new WhiteboardNoteSnapshot();
}
- var json = reader.GetString(0);
- if (string.IsNullOrWhiteSpace(json))
+ if (!IsExpired(legacySnapshot, normalizedRetentionDays))
{
- return new WhiteboardNoteSnapshot();
+ if (SaveNote(normalizedComponentId, normalizedPlacementId, legacySnapshot, normalizedRetentionDays))
+ {
+ _ = TryDeleteLegacyNote(normalizedComponentId, normalizedPlacementId);
+ }
+
+ return legacySnapshot.Clone();
}
- var snapshot = JsonSerializer.Deserialize(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
- if (!reader.IsDBNull(1))
- {
- snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
- }
-
- if (IsExpired(snapshot, retentionDays))
- {
- DeleteNote(normalizedComponentId, normalizedPlacementId);
- return new WhiteboardNoteSnapshot();
- }
-
- return snapshot.Clone();
+ _ = TryDeleteLegacyNote(normalizedComponentId, normalizedPlacementId);
}
- catch
+ catch (Exception ex)
{
- return new WhiteboardNoteSnapshot();
+ AppLogger.Warn(
+ Category,
+ $"Failed to load whiteboard note. ComponentId='{normalizedComponentId}'; PlacementId='{normalizedPlacementId}'.",
+ ex);
}
+
+ return new WhiteboardNoteSnapshot();
}
- public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
+ public bool SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
- return;
+ return false;
}
+ var notePath = GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
+ var tempPath = $"{notePath}.{Guid.NewGuid():N}.tmp";
+
try
{
var nowUtc = DateTimeOffset.UtcNow;
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
+ persistedSnapshot.Version = CurrentSnapshotVersion;
persistedSnapshot.SavedUtc = nowUtc;
- var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
- var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
+ persistedSnapshot.ExpiresUtc = nowUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
- using var connection = OpenConnection();
- using var command = connection.CreateCommand();
- command.CommandText = """
- INSERT INTO whiteboard_notes(
- component_id,
- placement_id,
- note_json,
- saved_at_utc_ms,
- expires_at_utc_ms,
- updated_at_utc_ms)
- VALUES(
- $componentId,
- $placementId,
- $noteJson,
- $savedAtUtcMs,
- $expiresAtUtcMs,
- $updatedAtUtcMs)
- ON CONFLICT(component_id, placement_id) DO UPDATE SET
- note_json = excluded.note_json,
- saved_at_utc_ms = excluded.saved_at_utc_ms,
- expires_at_utc_ms = excluded.expires_at_utc_ms,
- updated_at_utc_ms = excluded.updated_at_utc_ms;
- """;
- command.Parameters.AddWithValue("$componentId", normalizedComponentId);
- command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
- command.Parameters.AddWithValue("$noteJson", json);
- command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds());
- command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
- command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
- command.ExecuteNonQuery();
+ var directory = Path.GetDirectoryName(notePath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
+ File.WriteAllText(tempPath, json, Encoding.UTF8);
+ File.Move(tempPath, notePath, overwrite: true);
+ return true;
}
- catch
+ catch (Exception ex)
{
- // Keep whiteboard usable even when persistence is unavailable.
+ TryDeleteFile(tempPath);
+ AppLogger.Warn(
+ Category,
+ $"Failed to save whiteboard note. ComponentId='{normalizedComponentId}'; PlacementId='{normalizedPlacementId}'; StrokeCount={snapshot?.Strokes.Count ?? 0}.",
+ ex);
+ return false;
}
}
@@ -141,23 +146,9 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
return false;
}
- try
- {
- using var connection = OpenConnection();
- using var command = connection.CreateCommand();
- command.CommandText = """
- DELETE FROM whiteboard_notes
- WHERE component_id = $componentId
- AND placement_id = $placementId;
- """;
- command.Parameters.AddWithValue("$componentId", normalizedComponentId);
- command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
- return command.ExecuteNonQuery() > 0;
- }
- catch
- {
- return false;
- }
+ var deleted = TryDeleteFile(GetNoteFilePath(normalizedComponentId, normalizedPlacementId));
+ deleted |= TryDeleteLegacyNote(normalizedComponentId, normalizedPlacementId);
+ return deleted;
}
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
@@ -167,46 +158,72 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
return false;
}
+ var notePath = GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
try
{
- using var connection = OpenConnection();
- return DeleteExpiredInternal(
- connection,
- normalizedComponentId,
- normalizedPlacementId,
- WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
- DateTimeOffset.UtcNow);
+ if (File.Exists(notePath))
+ {
+ var snapshot = ReadSnapshot(notePath);
+ if (IsExpired(snapshot, retentionDays))
+ {
+ return TryDeleteFile(notePath);
+ }
+
+ return false;
+ }
+
+ return TryDeleteExpiredLegacyNote(normalizedComponentId, normalizedPlacementId, retentionDays);
}
- catch
+ catch (Exception ex)
{
+ AppLogger.Warn(
+ Category,
+ $"Failed to delete expired whiteboard note. ComponentId='{normalizedComponentId}'; PlacementId='{normalizedPlacementId}'.",
+ ex);
return false;
}
}
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
{
+ var deletedCount = 0;
+ var normalizedBatchSize = NormalizeBatchSize(batchSize);
+ var nowUtc = now ?? DateTimeOffset.UtcNow;
+
try
{
- using var connection = OpenConnection();
- using var command = connection.CreateCommand();
- command.CommandText = """
- DELETE FROM whiteboard_notes
- WHERE rowid IN (
- SELECT rowid
- FROM whiteboard_notes
- WHERE expires_at_utc_ms <= $nowUtcMs
- ORDER BY expires_at_utc_ms ASC
- LIMIT $batchSize
- );
- """;
- command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds());
- command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
- return command.ExecuteNonQuery();
+ if (Directory.Exists(_whiteboardsRootDirectory))
+ {
+ foreach (var notePath in Directory.EnumerateFiles(_whiteboardsRootDirectory, "*.json", SearchOption.AllDirectories))
+ {
+ if (deletedCount >= normalizedBatchSize)
+ {
+ break;
+ }
+
+ try
+ {
+ var snapshot = ReadSnapshot(notePath);
+ if (IsExpired(snapshot, WhiteboardNoteRetentionPolicy.DefaultDays, nowUtc) &&
+ TryDeleteFile(notePath))
+ {
+ deletedCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(Category, $"Failed to inspect whiteboard note file '{notePath}'.", ex);
+ }
+ }
+ }
}
- catch
+ catch (Exception ex)
{
- return 0;
+ AppLogger.Warn(Category, $"Failed to scan whiteboard note directory '{_whiteboardsRootDirectory}'.", ex);
}
+
+ deletedCount += DeleteExpiredLegacyNotesBatch(Math.Max(0, normalizedBatchSize - deletedCount), nowUtc);
+ return deletedCount;
}
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
@@ -227,7 +244,17 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
{
- if (snapshot is null || snapshot.SavedUtc == default)
+ if (snapshot is null)
+ {
+ return null;
+ }
+
+ if (snapshot.ExpiresUtc.HasValue)
+ {
+ return snapshot.ExpiresUtc.Value;
+ }
+
+ if (snapshot.SavedUtc == default)
{
return null;
}
@@ -235,23 +262,170 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
}
- private SqliteConnection OpenConnection()
+ internal string GetNoteFilePathForTests(string componentId, string? placementId)
{
- var connection = _databaseService.OpenConnection();
- EnsureSchema(connection);
+ if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
+ {
+ return string.Empty;
+ }
+
+ return GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
+ }
+
+ private string GetNoteFilePath(string normalizedComponentId, string normalizedPlacementId)
+ {
+ return Path.Combine(
+ _whiteboardsRootDirectory,
+ SanitizePathSegment(normalizedComponentId),
+ $"{SanitizePathSegment(normalizedPlacementId)}.json");
+ }
+
+ private static WhiteboardNoteSnapshot ReadSnapshot(string notePath)
+ {
+ var json = File.ReadAllText(notePath, Encoding.UTF8);
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return new WhiteboardNoteSnapshot();
+ }
+
+ return JsonSerializer.Deserialize(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
+ }
+
+ private WhiteboardNoteSnapshot TryLoadLegacyNote(string componentId, string placementId, int retentionDays)
+ {
+ try
+ {
+ using var connection = OpenLegacyConnection();
+ TryDeleteExpiredLegacyNote(connection, componentId, placementId, retentionDays, DateTimeOffset.UtcNow);
+
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT note_json, saved_at_utc_ms, expires_at_utc_ms
+ FROM whiteboard_notes
+ WHERE component_id = $componentId
+ AND placement_id = $placementId
+ LIMIT 1;
+ """;
+ command.Parameters.AddWithValue("$componentId", componentId);
+ command.Parameters.AddWithValue("$placementId", placementId);
+
+ using var reader = command.ExecuteReader();
+ if (!reader.Read() || reader.IsDBNull(0))
+ {
+ return new WhiteboardNoteSnapshot();
+ }
+
+ var snapshot = JsonSerializer.Deserialize(reader.GetString(0), JsonOptions) ??
+ new WhiteboardNoteSnapshot();
+ if (!reader.IsDBNull(1))
+ {
+ snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
+ }
+
+ if (!reader.IsDBNull(2))
+ {
+ snapshot.ExpiresUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(2));
+ }
+
+ return snapshot;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ Category,
+ $"Failed to load legacy whiteboard note. ComponentId='{componentId}'; PlacementId='{placementId}'.",
+ ex);
+ return new WhiteboardNoteSnapshot();
+ }
+ }
+
+ private bool TryDeleteLegacyNote(string componentId, string placementId)
+ {
+ try
+ {
+ using var connection = OpenLegacyConnection();
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ DELETE FROM whiteboard_notes
+ WHERE component_id = $componentId
+ AND placement_id = $placementId;
+ """;
+ command.Parameters.AddWithValue("$componentId", componentId);
+ command.Parameters.AddWithValue("$placementId", placementId);
+ return command.ExecuteNonQuery() > 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private bool TryDeleteExpiredLegacyNote(string componentId, string placementId, int retentionDays)
+ {
+ try
+ {
+ using var connection = OpenLegacyConnection();
+ return TryDeleteExpiredLegacyNote(
+ connection,
+ componentId,
+ placementId,
+ WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
+ DateTimeOffset.UtcNow);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private int DeleteExpiredLegacyNotesBatch(int batchSize, DateTimeOffset nowUtc)
+ {
+ if (batchSize <= 0)
+ {
+ return 0;
+ }
+
+ try
+ {
+ using var connection = OpenLegacyConnection();
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ DELETE FROM whiteboard_notes
+ WHERE rowid IN (
+ SELECT rowid
+ FROM whiteboard_notes
+ WHERE expires_at_utc_ms <= $nowUtcMs
+ ORDER BY expires_at_utc_ms ASC
+ LIMIT $batchSize
+ );
+ """;
+ command.Parameters.AddWithValue("$nowUtcMs", nowUtc.ToUnixTimeMilliseconds());
+ command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
+ return command.ExecuteNonQuery();
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ private SqliteConnection OpenLegacyConnection()
+ {
+ var connection = _legacyDatabaseService.OpenConnection();
+ EnsureLegacySchema(connection);
return connection;
}
- private void EnsureSchema(SqliteConnection connection)
+ private void EnsureLegacySchema(SqliteConnection connection)
{
- if (_schemaInitialized)
+ if (_legacySchemaInitialized)
{
return;
}
- lock (_schemaSyncRoot)
+ lock (_legacySchemaSyncRoot)
{
- if (_schemaInitialized)
+ if (_legacySchemaInitialized)
{
return;
}
@@ -272,11 +446,11 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
ON whiteboard_notes(expires_at_utc_ms);
""";
command.ExecuteNonQuery();
- _schemaInitialized = true;
+ _legacySchemaInitialized = true;
}
}
- private static bool DeleteExpiredInternal(
+ private static bool TryDeleteExpiredLegacyNote(
SqliteConnection connection,
string componentId,
string placementId,
@@ -326,7 +500,51 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
{
normalizedComponentId = componentId?.Trim() ?? string.Empty;
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
- return !string.IsNullOrWhiteSpace(normalizedComponentId);
+ return !string.IsNullOrWhiteSpace(normalizedComponentId) &&
+ !string.IsNullOrWhiteSpace(normalizedPlacementId);
+ }
+
+ private static string SanitizePathSegment(string value)
+ {
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var builder = new StringBuilder(value.Length);
+ foreach (var ch in value.Trim())
+ {
+ builder.Append(Array.IndexOf(invalidChars, ch) >= 0 ? '_' : ch);
+ }
+
+ var safe = builder.ToString();
+ if (string.IsNullOrWhiteSpace(safe))
+ {
+ return "_";
+ }
+
+ if (safe.Length <= 120)
+ {
+ return safe;
+ }
+
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(safe)))[..12].ToLowerInvariant();
+ return $"{safe[..100]}-{hash}";
+ }
+
+ private static bool TryDeleteFile(string path)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
+ {
+ File.SetAttributes(path, FileAttributes.Normal);
+ File.Delete(path);
+ return true;
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(Category, $"Failed to delete whiteboard note file '{path}'.", ex);
+ }
+
+ return false;
}
private static int NormalizeBatchSize(int batchSize)
diff --git a/LanMountainDesktop/Services/WhiteboardSvgImportService.cs b/LanMountainDesktop/Services/WhiteboardSvgImportService.cs
new file mode 100644
index 0000000..c9d83e7
--- /dev/null
+++ b/LanMountainDesktop/Services/WhiteboardSvgImportService.cs
@@ -0,0 +1,321 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using Avalonia.Media;
+using LanMountainDesktop.Models;
+using SkiaSharp;
+
+namespace LanMountainDesktop.Services;
+
+public sealed class WhiteboardSvgImportResult
+{
+ public List Strokes { get; init; } = [];
+
+ public int SkippedPathCount { get; init; }
+}
+
+public static class WhiteboardSvgImportService
+{
+ public static WhiteboardSvgImportResult Import(Stream stream, double targetWidth, double targetHeight)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+
+ var document = XDocument.Load(stream);
+ var root = document.Root;
+ if (root is null)
+ {
+ return new WhiteboardSvgImportResult();
+ }
+
+ var viewport = ResolveViewport(root);
+ var transform = ResolveTransform(viewport, targetWidth, targetHeight);
+ var importedStrokes = new List();
+ var skippedPathCount = 0;
+
+ foreach (var pathElement in root.Descendants().Where(static element =>
+ string.Equals(element.Name.LocalName, "path", StringComparison.OrdinalIgnoreCase)))
+ {
+ var pathData = pathElement.Attribute("d")?.Value;
+ if (string.IsNullOrWhiteSpace(pathData))
+ {
+ skippedPathCount++;
+ continue;
+ }
+
+ using var parsedPath = SKPath.ParseSvgPathData(pathData);
+ if (parsedPath is null || parsedPath.IsEmpty)
+ {
+ skippedPathCount++;
+ continue;
+ }
+
+ using var transformedPath = new SKPath(parsedPath);
+ transformedPath.Transform(transform);
+
+ var style = ParseStyle(pathElement.Attribute("style")?.Value);
+ var fillValue = ResolvePresentationValue(pathElement, style, "fill");
+ var strokeValue = ResolvePresentationValue(pathElement, style, "stroke");
+ var strokeWidth = ResolveStrokeWidth(pathElement, style) * ResolveStrokeScale(transform);
+
+ if (IsNone(fillValue) && TryParseSvgColor(strokeValue, out var strokeColor))
+ {
+ using var filledStrokePath = StrokePathToFillPath(transformedPath, strokeWidth);
+ if (filledStrokePath.IsEmpty)
+ {
+ skippedPathCount++;
+ continue;
+ }
+
+ importedStrokes.Add(CreateSnapshot(filledStrokePath, strokeColor, strokeWidth));
+ continue;
+ }
+
+ if (!TryParseSvgColor(fillValue, out var fillColor) &&
+ !TryParseSvgColor(strokeValue, out fillColor))
+ {
+ fillColor = SKColors.Black;
+ }
+
+ importedStrokes.Add(CreateSnapshot(transformedPath, fillColor, Math.Max(1d, strokeWidth)));
+ }
+
+ return new WhiteboardSvgImportResult
+ {
+ Strokes = importedStrokes,
+ SkippedPathCount = skippedPathCount
+ };
+ }
+
+ private static WhiteboardStrokeSnapshot CreateSnapshot(SKPath path, SKColor color, double inkThickness)
+ {
+ return new WhiteboardStrokeSnapshot
+ {
+ Color = ToHexColor(color),
+ InkThickness = Math.Max(0.5d, inkThickness),
+ IgnorePressure = true,
+ PathSvgData = path.ToSvgPathData()
+ };
+ }
+
+ private static SKPath StrokePathToFillPath(SKPath sourcePath, double strokeWidth)
+ {
+ var fillPath = new SKPath();
+ using var paint = new SKPaint
+ {
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = Math.Max(0.5f, (float)strokeWidth),
+ StrokeCap = SKStrokeCap.Round,
+ StrokeJoin = SKStrokeJoin.Round
+ };
+
+ if (!paint.GetFillPath(sourcePath, fillPath))
+ {
+ fillPath.Reset();
+ }
+
+ return fillPath;
+ }
+
+ private static SvgViewport ResolveViewport(XElement root)
+ {
+ var viewBox = root.Attribute("viewBox")?.Value;
+ if (!string.IsNullOrWhiteSpace(viewBox))
+ {
+ var parts = viewBox
+ .Split([' ', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(ParseSvgLength)
+ .ToArray();
+ if (parts.Length == 4 && parts[2] > 0 && parts[3] > 0)
+ {
+ return new SvgViewport(parts[0], parts[1], parts[2], parts[3]);
+ }
+ }
+
+ var width = ParseSvgLength(root.Attribute("width")?.Value);
+ var height = ParseSvgLength(root.Attribute("height")?.Value);
+ return new SvgViewport(0d, 0d, Math.Max(1d, width), Math.Max(1d, height));
+ }
+
+ private static SKMatrix ResolveTransform(SvgViewport viewport, double targetWidth, double targetHeight)
+ {
+ var scaleX = targetWidth > 0 ? targetWidth / viewport.Width : 1d;
+ var scaleY = targetHeight > 0 ? targetHeight / viewport.Height : 1d;
+ return new SKMatrix
+ {
+ ScaleX = (float)scaleX,
+ SkewX = 0f,
+ TransX = (float)(-viewport.X * scaleX),
+ SkewY = 0f,
+ ScaleY = (float)scaleY,
+ TransY = (float)(-viewport.Y * scaleY),
+ Persp0 = 0f,
+ Persp1 = 0f,
+ Persp2 = 1f
+ };
+ }
+
+ private static double ResolveStrokeScale(SKMatrix transform)
+ {
+ return Math.Max(0.01d, (Math.Abs(transform.ScaleX) + Math.Abs(transform.ScaleY)) * 0.5d);
+ }
+
+ private static Dictionary ParseStyle(string? value)
+ {
+ var style = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return style;
+ }
+
+ foreach (var declaration in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var separatorIndex = declaration.IndexOf(':', StringComparison.Ordinal);
+ if (separatorIndex <= 0 || separatorIndex >= declaration.Length - 1)
+ {
+ continue;
+ }
+
+ style[declaration[..separatorIndex].Trim()] = declaration[(separatorIndex + 1)..].Trim();
+ }
+
+ return style;
+ }
+
+ private static string? ResolvePresentationValue(
+ XElement pathElement,
+ IReadOnlyDictionary style,
+ string key)
+ {
+ if (pathElement.Attribute(key)?.Value is { } attributeValue)
+ {
+ return attributeValue;
+ }
+
+ return style.TryGetValue(key, out var styleValue) ? styleValue : null;
+ }
+
+ private static double ResolveStrokeWidth(XElement pathElement, IReadOnlyDictionary style)
+ {
+ var value = ResolvePresentationValue(pathElement, style, "stroke-width");
+ var parsed = ParseSvgLength(value);
+ return parsed > 0 ? parsed : 1d;
+ }
+
+ private static double ParseSvgLength(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return 0d;
+ }
+
+ var trimmed = value.Trim();
+ var end = 0;
+ while (end < trimmed.Length &&
+ (char.IsDigit(trimmed[end]) ||
+ trimmed[end] is '.' or '-' or '+' or 'e' or 'E'))
+ {
+ end++;
+ }
+
+ return double.TryParse(
+ trimmed[..end],
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out var parsed)
+ ? parsed
+ : 0d;
+ }
+
+ private static bool IsNone(string? value)
+ {
+ return string.Equals(value?.Trim(), "none", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool TryParseSvgColor(string? value, out SKColor color)
+ {
+ color = SKColors.Black;
+ if (string.IsNullOrWhiteSpace(value) || IsNone(value))
+ {
+ return false;
+ }
+
+ var trimmed = value.Trim();
+ if (string.Equals(trimmed, "transparent", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (TryParseShortHexColor(trimmed, out color))
+ {
+ return true;
+ }
+
+ try
+ {
+ var avaloniaColor = Color.Parse(trimmed);
+ color = new SKColor(avaloniaColor.R, avaloniaColor.G, avaloniaColor.B, avaloniaColor.A);
+ return color.Alpha > 0;
+ }
+ catch
+ {
+ return TryParseNamedColor(trimmed, out color);
+ }
+ }
+
+ private static bool TryParseShortHexColor(string value, out SKColor color)
+ {
+ color = SKColors.Black;
+ if (!value.StartsWith('#') || value.Length is not (4 or 5))
+ {
+ return false;
+ }
+
+ static byte Expand(char ch)
+ {
+ var value = Convert.ToByte(ch.ToString(), 16);
+ return (byte)((value << 4) | value);
+ }
+
+ try
+ {
+ var r = Expand(value[1]);
+ var g = Expand(value[2]);
+ var b = Expand(value[3]);
+ var a = value.Length == 5 ? Expand(value[4]) : (byte)255;
+ color = new SKColor(r, g, b, a);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryParseNamedColor(string value, out SKColor color)
+ {
+ color = value.Trim().ToLowerInvariant() switch
+ {
+ "black" => SKColors.Black,
+ "white" => SKColors.White,
+ "red" => SKColors.Red,
+ "green" => SKColors.Green,
+ "blue" => SKColors.Blue,
+ "yellow" => SKColors.Yellow,
+ "gray" or "grey" => SKColors.Gray,
+ _ => default
+ };
+
+ return color != default;
+ }
+
+ private static string ToHexColor(SKColor color)
+ {
+ return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
+ }
+
+ private readonly record struct SvgViewport(double X, double Y, double Width, double Height);
+}
diff --git a/LanMountainDesktop/Services/WindowMaterialService.cs b/LanMountainDesktop/Services/WindowMaterialService.cs
new file mode 100644
index 0000000..b98fe91
--- /dev/null
+++ b/LanMountainDesktop/Services/WindowMaterialService.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Media;
+using LanMountainDesktop.Services.Settings;
+using Microsoft.Win32;
+
+namespace LanMountainDesktop.Services;
+
+internal sealed class WindowMaterialService : IWindowMaterialService
+{
+ private const int Windows11Build = 22000;
+ private const int Windows11_24H2Build = 26100;
+
+ public bool CanChangeMode => GetAvailableModes().Count > 1;
+
+ public IReadOnlyList GetAvailableModes()
+ {
+ return GetSupportProfile() switch
+ {
+ WindowMaterialSupportProfile.FullSwitching =>
+ [
+ ThemeAppearanceValues.MaterialAuto,
+ ThemeAppearanceValues.MaterialNone,
+ ThemeAppearanceValues.MaterialMica,
+ ThemeAppearanceValues.MaterialAcrylic
+ ],
+ WindowMaterialSupportProfile.FixedMica =>
+ [
+ ThemeAppearanceValues.MaterialAuto,
+ ThemeAppearanceValues.MaterialNone,
+ ThemeAppearanceValues.MaterialMica
+ ],
+ WindowMaterialSupportProfile.FixedAcrylic =>
+ [
+ ThemeAppearanceValues.MaterialAuto,
+ ThemeAppearanceValues.MaterialNone,
+ ThemeAppearanceValues.MaterialAcrylic
+ ],
+ _ =>
+ [
+ ThemeAppearanceValues.MaterialAuto,
+ ThemeAppearanceValues.MaterialNone
+ ]
+ };
+ }
+
+ public void Apply(Window window, string materialMode)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
+ var supportProfile = GetSupportProfile();
+ var effectiveMode = normalizedMode == ThemeAppearanceValues.MaterialAuto
+ ? ResolveAutoMaterialMode(supportProfile)
+ : normalizedMode;
+
+ if (effectiveMode == ThemeAppearanceValues.MaterialNone)
+ {
+ window.Background = Brushes.White;
+ window.TransparencyLevelHint = [WindowTransparencyLevel.None];
+ return;
+ }
+
+ window.Background = Brushes.Transparent;
+
+ if (supportProfile == WindowMaterialSupportProfile.NoneOnly)
+ {
+ window.TransparencyLevelHint =
+ [
+ WindowTransparencyLevel.None
+ ];
+ return;
+ }
+
+ window.TransparencyLevelHint = normalizedMode == ThemeAppearanceValues.MaterialAuto
+ ? ResolveAutoTransparencyLevels(supportProfile)
+ : effectiveMode switch
+ {
+ ThemeAppearanceValues.MaterialMica =>
+ [
+ WindowTransparencyLevel.Mica,
+ WindowTransparencyLevel.Blur,
+ WindowTransparencyLevel.None
+ ],
+ ThemeAppearanceValues.MaterialAcrylic =>
+ [
+ WindowTransparencyLevel.AcrylicBlur,
+ WindowTransparencyLevel.Blur,
+ WindowTransparencyLevel.None
+ ],
+ _ =>
+ [
+ WindowTransparencyLevel.None
+ ]
+ };
+ }
+
+ private static string ResolveAutoMaterialMode(WindowMaterialSupportProfile supportProfile)
+ {
+ return supportProfile switch
+ {
+ WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica =>
+ ThemeAppearanceValues.MaterialMica,
+ WindowMaterialSupportProfile.FixedAcrylic =>
+ ThemeAppearanceValues.MaterialAcrylic,
+ _ => ThemeAppearanceValues.MaterialNone
+ };
+ }
+
+ private static IReadOnlyList ResolveAutoTransparencyLevels(WindowMaterialSupportProfile supportProfile)
+ {
+ return supportProfile switch
+ {
+ WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica =>
+ [
+ WindowTransparencyLevel.Mica,
+ WindowTransparencyLevel.AcrylicBlur,
+ WindowTransparencyLevel.Blur,
+ WindowTransparencyLevel.None
+ ],
+ WindowMaterialSupportProfile.FixedAcrylic =>
+ [
+ WindowTransparencyLevel.AcrylicBlur,
+ WindowTransparencyLevel.Blur,
+ WindowTransparencyLevel.None
+ ],
+ _ =>
+ [
+ WindowTransparencyLevel.None
+ ]
+ };
+ }
+
+ private static bool IsTransparencyEnabled()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(
+ @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
+ writable: false);
+ var value = key?.GetValue("EnableTransparency");
+ return value switch
+ {
+ int intValue => intValue != 0,
+ byte byteValue => byteValue != 0,
+ _ => true
+ };
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ private static WindowMaterialSupportProfile GetSupportProfile()
+ {
+ if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
+ {
+ return WindowMaterialSupportProfile.NoneOnly;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11_24H2Build))
+ {
+ return WindowMaterialSupportProfile.FullSwitching;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11Build))
+ {
+ return WindowMaterialSupportProfile.FixedMica;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0))
+ {
+ return WindowMaterialSupportProfile.FixedAcrylic;
+ }
+
+ return WindowMaterialSupportProfile.NoneOnly;
+ }
+
+ private enum WindowMaterialSupportProfile
+ {
+ NoneOnly = 0,
+ FixedMica = 1,
+ FixedAcrylic = 2,
+ FullSwitching = 3
+ }
+}
diff --git a/LanMountainDesktop/Services/WindowPassthroughService.cs b/LanMountainDesktop/Services/WindowPassthroughService.cs
index 34af4c5..44c2be4 100644
--- a/LanMountainDesktop/Services/WindowPassthroughService.cs
+++ b/LanMountainDesktop/Services/WindowPassthroughService.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Avalonia;
@@ -7,9 +6,6 @@ using Avalonia.Controls;
namespace LanMountainDesktop.Services;
-///
-/// 窗口置底服务接口
-///
public interface IWindowBottomMostService
{
void SetupBottomMost(Window window);
@@ -17,35 +13,18 @@ public interface IWindowBottomMostService
bool IsBottomMostSupported { get; }
}
-///
-/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现
-///
public interface IRegionPassthroughService
{
- ///
- /// 设置窗口的可交互区域
- ///
void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions);
-
- ///
- /// 清除所有可交互区域
- ///
void ClearInteractiveRegions(Window window);
-
- ///
- /// 获取当前平台是否支持区域级穿透
- ///
bool IsRegionPassthroughSupported { get; }
}
-///
-/// 窗口置底服务工厂
-///
public static class WindowBottomMostServiceFactory
{
private static IWindowBottomMostService? _instance;
private static readonly object _lock = new();
-
+
public static IWindowBottomMostService GetOrCreate()
{
lock (_lock)
@@ -57,14 +36,11 @@ public static class WindowBottomMostServiceFactory
}
}
-///
-/// 区域级穿透服务工厂
-///
public static class RegionPassthroughServiceFactory
{
private static IRegionPassthroughService? _instance;
private static readonly object _lock = new();
-
+
public static IRegionPassthroughService GetOrCreate()
{
lock (_lock)
@@ -76,335 +52,334 @@ public static class RegionPassthroughServiceFactory
}
}
-///
-/// Windows 平台窗口置底服务
-///
internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
{
+ private const int GWL_STYLE = -16;
private const int GWL_EXSTYLE = -20;
- private const int GWL_HWNDPARENT = -8;
private const int GWLP_WNDPROC = -4;
- private const int WS_EX_TOOLWINDOW = 0x00000080;
- private const int WS_EX_APPWINDOW = 0x00040000;
- private const int WS_EX_NOACTIVATE = 0x08000000;
- private const int WS_EX_LAYERED = 0x00080000;
+
+ private const long WS_CHILD = 0x40000000L;
+ private const long WS_POPUP = 0x80000000L;
+ private const long WS_CAPTION = 0x00C00000L;
+ private const long WS_THICKFRAME = 0x00040000L;
+ private const long WS_MINIMIZEBOX = 0x00020000L;
+ private const long WS_MAXIMIZEBOX = 0x00010000L;
+ private const long WS_SYSMENU = 0x00080000L;
+
+ private const long WS_EX_TOOLWINDOW = 0x00000080L;
+ private const long WS_EX_APPWINDOW = 0x00040000L;
+ private const long WS_EX_NOACTIVATE = 0x08000000L;
+ private const long WS_EX_LAYERED = 0x00080000L;
+
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOACTIVATE = 0x0010;
- private const int WM_WINDOWPOSCHANGING = 0x0046;
+ private const uint SWP_SHOWWINDOW = 0x0040;
+
private const int WM_NCHITTEST = 0x0084;
- private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息
private const int HTTRANSPARENT = -1;
private const int HTCLIENT = 1;
-
+
+ private const int MONITOR_DEFAULTTONEAREST = 2;
+ private const int MDT_EFFECTIVE_DPI = 0;
+
+ private static readonly IntPtr HWND_TOP = IntPtr.Zero;
private static readonly IntPtr HWND_BOTTOM = new(1);
- private static readonly Dictionary _bottomMostWindows = new();
+ private static readonly object _staticLock = new();
+ private static readonly object _timerLock = new();
+
+ private static readonly Dictionary _desktopWindows = new();
private static readonly Dictionary _originalWndProcs = new();
private static readonly Dictionary