Compare commits

..

7 Commits

Author SHA1 Message Date
lincube
f8073c2020 fix.修复合并产生的问题。 2026-04-29 12:07:26 +08:00
lincube
ae3938ce83 Merge remote-tracking branch 'origin/main' into Avalonia12
# Conflicts:
#	LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
2026-04-29 11:49:48 +08:00
lincube
0f8e51fb68 Update icon glyphs and symbol mappings
Replace and refine icon sources across settings pages and controls: many FAFontIconSource glyphs were updated to specific Seagull Fluent Icons codepoints, some FASymbolIconSource usages were replaced with FAFontIconSource, and a number of symbol-to-Symbol enum mappings were adjusted (e.g. "Bell" -> AlertOn, "Shield" -> ShieldLock). Also clarified a comment in SettingsWindow and fixed a trailing newline in StudySettingsPage. Changes standardize icon visuals and bridge FluentIcons glyphs into FluentAvalonia icon sources.
2026-04-29 11:01:58 +08:00
lincube
93d6d93815 Migrate to Avalonia 12 and Plugin SDK v5
Upgrade project to the Avalonia 12 baseline and Plugin SDK v5: centralize Avalonia packages, remove legacy WebView.Avalonia usage (use NativeWebView/WebView2 EnvironmentRequested), and update Fluent/Material icon/package usages. Bump multiple package/project versions to 5.0.0 and Avalonia 12.0.1, update plugin template and README/docs to SDK v5, and add PLUGIN_SDK_V5_MIGRATION.md.

Also fix runtime/behavior bugs: make DataLocationResolver use a fixed bootstrap launcher data path and avoid recursive ResolveDataRoot; add legacy-state handling and extraction in OobeStateService; and update component settings tests to reflect migrated storage (DB/backup) and reset cache for test reloads. Various csproj, tests, and docs updated to reflect the migration and ensure build/test compatibility.
2026-04-29 10:16:25 +08:00
lincube
9fb41378eb Migrate codebase to Avalonia 12 APIs
Apply Avalonia 12 migration changes: replace SystemDecorations with WindowDecorations and remove ExtendClientAreaChromeHints/ExtendClientAreaTitleBarHeightHint usages; update BindingPlugins removal logic (no-op); switch clipboard usage to ClipboardExtensions.SetTextAsync; update Bitmap.CopyPixels calls to the new signature. Replace TextBox.Watermark with PlaceholderText, convert NumberBox styles to FANumberBox and adjust templates, change Checked/Unchecked handlers to IsCheckedChanged, and adapt FluentIcons usages (SymbolIconSource -> FASymbol/FAFont/FluentIcon equivalents). Fix MainWindow partial classes to inherit Window and correct missing variables/fields/usings. Add migration docs/specs/tasks under .trae and include a small TestFluentIcons project for icon testing.
2026-04-29 08:50:28 +08:00
lincube
a73ba32700 Enable centralized package versioning
Add <Project> and <PropertyGroup> with <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> to Directory.Packages.props to enable centralized package version management across the repository. This allows package versions to be controlled from this single file instead of individual project files.
2026-04-25 22:51:43 +08:00
lincube
d310fc50ac ava12升级 2026-04-25 22:51:18 +08:00
1214 changed files with 29042 additions and 100639 deletions

View File

@@ -1,5 +1,3 @@
{
"diffEditor.renderSideBySide": false,
"clawMode.mode": "editor",
"workbench.activityBar.location": "default"
"diffEditor.renderSideBySide": false
}

View File

@@ -1,13 +0,0 @@
{
"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\")"
]
}
}

View File

@@ -1,291 +0,0 @@
# 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
<UserControl x:Class="LanMountainDesktop.Views.Components.StandbyDigitalClockWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="14">
<!-- 背景在代码后置中设置渐变与AnalogClockWidget一致 -->
<Viewbox Stretch="Uniform">
<Grid Width="400" Height="200">
<StackPanel VerticalAlignment="Center"
HorizontalAlignment="Center"
Orientation="Horizontal">
<!-- H1 数位 -->
<Border x:Name="H1Clip" ClipToBounds="True" ...>
<Panel x:Name="H1Stack" ...>
<TextBlock x:Name="H1Text" Text="0" ... />
</Panel>
</Border>
<!-- H2 数位 -->
<Border x:Name="H2Clip" ClipToBounds="True" ...>
<Panel x:Name="H2Stack" ...>
<TextBlock x:Name="H2Text" Text="0" ... />
</Panel>
</Border>
<!-- 冒号 -->
<TextBlock x:Name="ColonText" Text=":" ... />
<!-- M1 数位 -->
<Border x:Name="M1Clip" ClipToBounds="True" ...>
<Panel x:Name="M1Stack" ...>
<TextBlock x:Name="M1Text" Text="0" ... />
</Panel>
</Border>
<!-- M2 数位 -->
<Border x:Name="M2Clip" ClipToBounds="True" ...>
<Panel x:Name="M2Stack" ...>
<TextBlock x:Name="M2Text" Text="0" ... />
</Panel>
</Border>
</StackPanel>
<!-- 日期行 -->
<TextBlock x:Name="DateTextBlock"
VerticalAlignment="Bottom"
HorizontalAlignment="Center" ... />
</Grid>
</Viewbox>
</Border>
</UserControl>
```
### 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 比例规则)

View File

@@ -1,52 +0,0 @@
# 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
### 冒号呼吸
- 每秒切换 Opacity1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡
### 日/夜模式
- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算
- 夜间:深色渐变背景 + 亮调强调色数字
- 日间:浅色渐变背景 + 深调强调色数字
### 组件规格
- 尺寸4×2 (MinWidthCells=4, MinHeightCells=2)
- 分类Clock
- 缩放2:1 比例 (Proportional)
- 字体FontWeight.Bold, 120px 基准

View File

@@ -1,25 +0,0 @@
# 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`,定义 RootBorderDesignCornerRadiusComponent、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

View File

@@ -1,432 +0,0 @@
---
name: Launcher 单项目解耦
overview: 在保持单一 LanMountainDesktop.Launcher 项目、单一 exe、零部署风险的前提下按职责域增量重构目录分层、RunAsync→Pipeline+Phase、UpdateEngine→策略类、App→纯 Avalonia+LauncherOrchestrator执行过程中由 Agent 自主 Git 提交,每域可编译可测。
todos:
- id: phase-a-diagnostics
content: Phase AStartup 诊断 + HostStartupMonitor 独立类 + AOT 启动检测竞态修复 + 测试
status: completed
- id: phase-b-directory
content: Phase B1职责域目录迁移Deployment/Update/Startup/Oobe/Plugins/Infrastructure零逻辑变更提交
status: completed
- id: phase-b-pipeline
content: Phase B2RunAsync→LaunchPipeline+ILaunchPhase引入 LauncherOrchestrator删除 LauncherFlowCoordinator提交
status: completed
- id: phase-b-app-slim
content: Phase B3App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator提交
status: completed
- id: phase-c-di
content: Phase CLauncherServiceRegistration + 轻量 MS DI统一 CLI/GUI 装配,提交
status: completed
- id: phase-d-update-split
content: Phase DUpdateEngineService→门面+策略类Verifier/Activator/Rollback 等),提交
status: completed
- id: phase-e-guardrails
content: Phase ELauncherArchitectureTests + 文档 + AOT 回归,提交
status: completed
isProject: false
---
# Launcher 单项目内部解耦改造计划(执行版)
## 0. 硬性约束
| 约束 | 说明 |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **单项目** | 仅 `[LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj](LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj)`,不新建 Launcher.* 独立程序集 |
| **单 exe** | 仍只发布 `LanMountainDesktop.Launcher.exe`AOT 单文件) |
| **零部署风险** | 不改变安装包目录结构、不引入新进程、不改变 Public IPC / Coordinator IPC 拓扑与契约 |
| **增量重构** | 一个职责域一域推进,每步 `dotnet build` + 相关 `dotnet test` 通过后再进下一步 |
| **单进程性能** | 模块间仅 in-process 接口调用,不为解耦新增 IPC |
| **未来可拆** | 各域暴露 `I`* 接口,将来若需多进程可直接复用契约 |
| **Git 自主提交** | Agent 在每个职责域完成且验证通过后 **自动 commit**,无需用户手动提交(见 §8 |
外部共享库 `[LanMountainDesktop.PluginPackaging](LanMountainDesktop.PluginPackaging/)` 保留Host + Launcher CLI 共用),不属于 Launcher 拆分。
---
## 1. 验收标准(必须全部满足)
### 1.1 零部署风险
- Inno Setup / CI 产物仍只有:`LanMountainDesktop.Launcher.exe` + `app-{version}/` + `.launcher/`
- Host 调用 Launcher 的 CLI 参数、`launch-source``apply-update` 路径不变
- Public IPC routes`lanmountain.launcher.startup-progress``loading-state`)与 Coordinator pipe 不变
- VeloPack / 更新 apply 状态机(`.current/.partial/.destroy`)行为不变
### 1.2 增量可验证
- 每个 Phase 结束:编译绿 + 该域新增/既有测试绿
- 允许「纯移动文件」的 PR 单独提交,行为 diff 为零
### 1.3 测试友好
- `Startup/``Update/``Deployment/` 内类型 **无 Avalonia 依赖**,可独立单元测试
- 每个 `ILaunchPhase`、每个 Update 策略类各有对应测试类
- 保留并扩展现有 `[LauncherStartupTimeoutPolicyTests](LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs)``[LauncherMultiInstancePolicyTests](LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs)`
### 1.4 启动性能
- Pipeline 阶段为同步/异步方法调用链,不引入额外进程或网络
- DI 容器仅在进程入口构建一次Stage/Phase 实例可复用 Singleton
### 1.5 代码结构目标
| 对象 | 当前(实测) | 目标 |
| ----------------------------------- | -------------------------------------------- | --------------------------------------------------- |
| `LauncherFlowCoordinator` 全 partial | ~1880 行859+568+279+…) | **删除**;逻辑迁入 Pipeline + Phases |
| `RunAsync()` 等价逻辑 | 跨 partial ~800+ 行 while/阶段混杂 | **≤80 行** 编排入口,细节在各 Phase |
| `UpdateEngineService` | ~1622 行 | 门面 **≤200 行** + 6 个策略类各 **≤300 行** |
| `App.axaml.cs` | ~258 行(已部分瘦身) | **≤120 行**:纯 Avalonia + 一行委托 `LauncherOrchestrator` |
| `LauncherOrchestrator` | 不存在(逻辑在 Coordinator + CompositionRoot 546 行) | **≤250 行**GUI 入口编排 |
| `LauncherCompositionRoot` | ~546 行 | **≤150 行**:仅 DI 构建 + 入口分发 |
---
## 2. 目标架构
### 2.1 核心类型关系
```mermaid
flowchart TB
Program --> EntryRouter
App --> LauncherOrchestrator
EntryRouter --> LauncherOrchestrator
LauncherOrchestrator --> LaunchPipeline
LaunchPipeline --> Phase1[CleanupPhase]
LaunchPipeline --> Phase2[OobeGatePhase]
LaunchPipeline --> Phase3[ApplyUpdatePhase]
LaunchPipeline --> Phase4[LaunchHostPhase]
LaunchPipeline --> Phase5[MonitorStartupPhase]
Phase3 --> IUpdateEngine
Phase4 --> IDeploymentLocator
Phase5 --> IHostStartupMonitor
LauncherCompositionRoot --> ServiceProvider
ServiceProvider --> LaunchPipeline
```
**命名约定:**
- `**LauncherOrchestrator`**GUI 生命周期内的唯一编排入口(取代 `LauncherFlowCoordinator` 对外角色)
- `**LaunchPipeline**`:按序执行 `ILaunchPhase` 列表
- `**ILaunchPhase**`:原 `ILaunchPipelineStage`;每个 Phase 对应原 `RunAsync` 中一个职责段
### 2.2 职责域目录(单项目内)
```
LanMountainDesktop.Launcher/
├── Program.cs # CLI / GUI 路由
├── App.axaml.cs # 纯 Avalonia≤120 行)
├── Shell/
│ ├── LauncherOrchestrator.cs # GUI 编排入口
│ ├── LauncherCompositionRoot.cs # DI + Entry 分发
│ ├── LaunchPipeline.cs
│ ├── Phases/ # ILaunchPhase 实现
│ │ ├── CleanupDeploymentsPhase.cs
│ │ ├── OobeGatePhase.cs
│ │ ├── ApplyPendingUpdatePhase.cs
│ │ ├── LaunchHostPhase.cs
│ │ └── MonitorStartupPhase.cs
│ └── EntryHandlers/ # apply-update / air-app-broker / attach
├── Deployment/
├── Update/
│ ├── IUpdateEngine.cs
│ ├── UpdateEngineFacade.cs # 原 UpdateEngineService 门面
│ └── Strategies/
│ ├── PendingUpdateDetector.cs
│ ├── UpdatePackageVerifier.cs
│ ├── DeploymentActivator.cs
│ ├── UpdateSnapshotStore.cs
│ ├── RollbackStrategy.cs
│ └── IncomingArtifactsCleaner.cs
├── Startup/
├── Oobe/
├── Ipc/
├── AirApp/
├── Plugins/
├── Infrastructure/
├── Models/
└── Views/
```
### 2.3 模块依赖规则
- `Deployment/``Update/``Startup/`**禁止** `using Avalonia`
- `Views/`**禁止** 引用具体 `UpdateEngineFacade` / `DeploymentLocator`(仅接口或 Orchestrator
- 跨域:**仅通过 `I`* 接口**Orchestrator/Pipeline 负责装配
### 2.4 与 Host 边界(不变)
| 能力 | Owner |
| -------------------------- | ------------------------------ |
| OOBE / Splash / 多实例 / 启动检测 | Launcher `Startup/` + `Shell/` |
| 更新 apply / rollback | Launcher `Update/` |
| 插件市场 / pending | Host + PluginPackaging |
| 更新 download | Host → spawn Launcher apply |
---
## 3. 三大核心拆分(用户指定)
### 3.1 拆分 `LauncherFlowCoordinator``RunAsync` → Pipeline + Phase
**现状:** 逻辑分散在 4 个 partial等效一个 1800+ 行 God Class`RunAsync` 内含清理、OOBE、更新、启动、IPC 监听、超时 while-loop、多实例分支。
**目标 API单项目 `Shell/` 内):**
```csharp
internal interface ILaunchPhase
{
string PhaseId { get; }
/// <returns>null = 继续下一阶段;非 null = 管道终止并返回结果</returns>
Task<LauncherResult?> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken);
}
internal sealed class LaunchPipeline
{
public LaunchPipeline(IEnumerable<ILaunchPhase> phases) { ... }
public Task<LauncherResult> RunAsync(LaunchContext context, CancellationToken ct);
}
```
**Phase 映射(与原 RunAsync 步骤一一对应):**
| Phase | 原 RunAsync 段 | 产出 |
| ------------------------- | --------------------------------------- | ----------------------------- |
| `CleanupDeploymentsPhase` | `CleanupOldDeployments` | 无 UI |
| `ExistingHostProbePhase` | 多实例 / Public IPC 探测 | 可短路成功 |
| `ApplyPendingUpdatePhase` | `_updateEngine.ApplyPendingUpdateAsync` | 失败仍继续 |
| `OobeGatePhase` | migration + OOBE steps | UI via `ILauncherUiPresenter` |
| `LaunchHostPhase` | `LaunchHostWithIpcAsync` | Process + plan |
| `MonitorStartupPhase` | while-loop + IPC + timeout | 调用 `IHostStartupMonitor` |
`**LauncherOrchestrator` 职责:**
- 接收 `SplashWindow`、构建 `LaunchContext`(含 reporter、attempt registry、coordinator server
- 调用 `LaunchPipeline.RunAsync`
- 管理 Splash/Error 窗口生命周期(委托 `ILauncherUiPresenter`
- **不含** 更新/部署/IPC 细节
**删除清单:** `LauncherFlowCoordinator.cs` 及全部 partial 文件。
---
### 3.2 拆分 `UpdateEngineService` → 门面 + 策略类
**现状:** ~1622 行单文件,混合检测、验签、解压、激活、快照、回滚、清理。
**目标结构:**
```
Update/
├── IUpdateEngine.cs # 对外契约(未来多进程可原样抽出)
├── UpdateEngineFacade.cs # 门面编排策略≤200 行
└── Strategies/
├── IUpdateStrategy.cs # 可选:各策略统一接口
├── PendingUpdateDetector.cs # CheckPendingUpdate
├── UpdatePackageVerifier.cs # manifest + RSA 签名
├── UpdatePackageExtractor.cs # 解压 / 增量复用
├── DeploymentActivator.cs # .current / .partial / .destroy
├── UpdateSnapshotStore.cs # snapshots 读写
├── RollbackStrategy.cs # rollback CLI/GUI
└── IncomingArtifactsCleaner.cs # CleanupIncomingArtifacts
```
**门面方法映射:**
| 原 `UpdateEngineService` 公开方法 | 委托策略 |
| ---------------------------- | ------------------------------------------------------ |
| `CheckPendingUpdate()` | `PendingUpdateDetector` |
| `ApplyPendingUpdateAsync()` | Detector → Verifier → Extractor → Activator → Snapshot |
| `RollbackLatest()` | `RollbackStrategy` |
| `CleanupIncomingArtifacts()` | `IncomingArtifactsCleaner` |
| `DownloadAsync()`(若有) | 保持或拆 `UpdateDownloader` |
**测试:** 每个 Strategy 独立 mock `IDeploymentLocator` / 文件系统,不启 Avalonia。
---
### 3.3 精简 `App.axaml.cs` → 纯 Avalonia + `LauncherOrchestrator`
**现状:** ~258 行,仍含 apply-update、air-app-broker、preview、coordinator attach 等分支。
**目标结构:**
```csharp
// App.axaml.cs 目标形态(概念)
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var context = LauncherRuntimeContext.Current;
var mode = LauncherEntryModeResolver.Resolve(context);
_ = LauncherOrchestrator.RunAsync(desktop, context, mode);
}
base.OnFrameworkInitializationCompleted();
}
```
**从 App 迁出的逻辑 → `Shell/EntryHandlers/`**
| 现 App 分支 | 新 Handler |
| ----------------- | -------------------------------------- |
| `launch` + splash | `GuiLaunchEntryHandler` → Orchestrator |
| `apply-update` | `ApplyUpdateEntryHandler` |
| `air-app-broker` | `AirAppBrokerEntryHandler` |
| debug preview | `PreviewEntryHandler` |
**验收:** `App.axaml.cs` ≤120 行;不含 `new UpdateEngineService` / `new DeploymentLocator` / while-loop。
---
## 4. 分阶段执行顺序与 Git 提交点
```mermaid
flowchart LR
A[Phase A Startup] --> B1[Phase B1 目录迁移]
B1 --> B2[Phase B2 Pipeline+Orchestrator]
B2 --> B3[Phase B3 App 精简]
B3 --> C[Phase C DI]
B1 --> D[Phase D Update 策略拆分]
C --> E[Phase E 守卫+文档+AOT回归]
D --> E
```
### Phase AStartup 子系统 + AOT 生产 bug优先
- 抽出 `Startup/HostStartupMonitor.cs`(从 partial 独立)
- 修复 IPC 连接退避、成功判定统一走 `StartupSuccessTracker`
- Host 侧 `DesktopVisible` 上报对齐(仅日志/时序,不改 IPC 契约)
- 测试 + `**git commit**`: `fix(launcher): extract HostStartupMonitor and harden startup detection`
### Phase B1目录迁移零逻辑变更
- 物理移动文件到 `Deployment/``Update/``Startup/` 等,更新 namespace
- `dotnet build` + test
- `**git commit**`: `refactor(launcher): reorganize into responsibility folders`
### Phase B2Pipeline + Phase + LauncherOrchestrator
- 实现 `ILaunchPhase``LaunchPipeline``LauncherOrchestrator`
- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试)
- 删除 `LauncherFlowCoordinator*`
- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline`
### Phase B3App.axaml.cs 精简
- EntryHandlers 提取App 仅 Avalonia + Orchestrator 委托
- `**git commit**`: `refactor(launcher): slim App.axaml.cs to Avalonia shell only`
### Phase C轻量 DI
- `LauncherServiceRegistration.cs` + `Microsoft.Extensions.DependencyInjection`
- Program / CliHost / CompositionRoot 统一 `ServiceProvider`
- `**git commit**`: `refactor(launcher): add composition-root DI wiring`
### Phase DUpdateEngine 策略拆分(可与 B2 并行,依赖 B1
- 策略类提取 + `UpdateEngineFacade`
- 删除原巨型 `UpdateEngineService.cs`
- 每策略测试
- `**git commit**`: `refactor(launcher): split UpdateEngine into strategy classes`
### Phase E守卫 + 文档 + AOT 回归
- `LauncherArchitectureTests`(命名空间依赖规则)
- 更新 `[docs/LAUNCHER.md](docs/LAUNCHER.md)``[.trae/specs/launcher-shell-hardening/spec.md](.trae/specs/launcher-shell-hardening/spec.md)`
- AOT publish 本地 smokelaunch / apply-update / 多实例 / 启动检测
- `**git commit**`: `docs(launcher): document module boundaries and add architecture tests`
---
## 5. Phase / Service 测试矩阵
| 组件 | 测试文件 | 覆盖点 |
| ----------------------- | ---------------------------- | --------------------------------- |
| `StartupSuccessTracker` | `StartupSuccessTrackerTests` | Foreground/Tray/Background policy |
| `HostStartupMonitor` | `HostStartupMonitorTests` | 超时、IPC 延迟、ShellStatus 轮询 |
| `LaunchPipeline` | `LaunchPipelineTests` | Phase 短路、失败传播 |
| 各 `ILaunchPhase` | `*PhaseTests` | 单阶段 mock |
| `PendingUpdateDetector` | `PendingUpdateDetectorTests` | 无 pending / corrupt |
| `DeploymentActivator` | `DeploymentActivatorTests` | 标记文件状态机 |
| `RollbackStrategy` | `RollbackStrategyTests` | 快照回退 |
| 命名空间规则 | `LauncherArchitectureTests` | 无 Avalonia 泄漏 |
---
## 6. 明确不做
- 不新建 csprojLauncher.Deployment 等)
- 不新建 exe / Windows Service
- 不改变 Public IPC / Coordinator IPC 协议
- 不把插件市场安装迁回 Launcher
- 不为模块间通信引入新 IPC仅保留现有 Host↔Launcher 契约)
---
## 7. 风险与缓解
| 风险 | 缓解 |
| --------------- | ------------------------------------------------------------------ |
| 大规模移动 merge 冲突 | B1 独立 commit零逻辑变更 |
| Pipeline 迁移行为回归 | 先写 Phase 级测试再迁代码;保留 `LMD_LAUNCHER_LEGACY_COORDINATOR=1` 开关一个版本(可选) |
| AOT + DI | 显式注册,禁止反射扫描;`PublishAot` CI 步骤验证 |
| Update 拆分遗漏路径 | CLI `update *` 与 GUI apply-update 同一 `IUpdateEngine` 门面 |
---
## 8. Git 工作流Agent 自主提交)
**原则:** 每个 Phase 验证通过后立即提交;不累积巨型 uncommitted diff。
**Commit 前检查(每个 commit 必做):**
```bash
dotnet build LanMountainDesktop.slnx -c Debug
dotnet test LanMountainDesktop.slnx -c Debug --filter "FullyQualifiedName~Launcher"
```
**Commit message 风格(与仓库一致):**
```
refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline
Pipeline + Phase pattern; LauncherOrchestrator becomes GUI entry.
No deployment or IPC contract changes.
```
**禁止:** `git push --force`、修改 git config、跳过 hooks除非 hook 失败需修复后新 commit
**建议分支:** `refactor/launcher-internal-modularization`(单 long-lived 分支,按 Phase 连续 commit或每 Phase 一个 PR 由用户决定 merge 时机)。
---
## 9. 整体完成定义Definition of Done
-`LauncherFlowCoordinator` 源文件
- `App.axaml.cs` ≤120 行,仅 Avalonia + Orchestrator 委托
- `UpdateEngineService` 巨型文件已替换为 Facade + Strategies
- 职责域目录就位,架构测试通过
- 全量 Launcher 相关测试 + AOT publish smoke 通过
- 安装包结构与 IPC 拓扑与重构前一致
- 每个 Phase 有对应 Git commit工作区 clean

View File

@@ -1 +0,0 @@

166
.github/workflows/ddss-publish.yml vendored Normal file
View File

@@ -0,0 +1,166 @@
name: DDSS
on:
workflow_run:
workflows:
- PLONDS
types:
- completed
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
env:
DOTNET_VERSION: '10.0.x'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "$RAW_TAG" == v* ]]; then
TAG="$RAW_TAG"
else
TAG="v$RAW_TAG"
fi
else
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p release-assets
gh release download "$RELEASE_TAG" -D release-assets
find release-assets -maxdepth 1 -type f | sort
- name: Upload release assets 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 --version
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
if [[ "$existing_sha" == "$sha256" ]]; then
echo "Skip existing asset: $name"
continue
fi
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: |
set -euo pipefail
mkdir -p ddss-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-ddss \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir ddss-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Upload DDSS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
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
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 file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${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

235
.github/workflows/plonds-build.yml vendored Normal file
View File

@@ -0,0 +1,235 @@
name: PLONDS
on:
release:
types:
- published
- prereleased
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
baseline_tag:
description: 'Optional baseline tag'
required: false
type: string
channel:
description: 'Update channel'
required: false
type: choice
default: stable
options:
- stable
- preview
env:
DOTNET_VERSION: '10.0.x'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release context
shell: bash
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
CHANNEL="preview"
else
CHANNEL="stable"
fi
BASELINE_TAG=""
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Resolve baseline plan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$tag = $env:RELEASE_TAG
$baselineInput = $env:BASELINE_TAG_INPUT
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
$entries = foreach ($platform in $platforms) {
$assetName = "files-$platform.zip"
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if (-not $currentAsset) {
throw "Current release $tag does not contain required asset $assetName"
}
$baselineRelease = $null
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
if (-not $baselineRelease) {
throw "Specified baseline tag not found: $normalizedBaseline"
}
}
else {
$baselineRelease = $allReleases |
Where-Object {
$_.tag_name -ne $tag -and
-not $_.draft -and
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
} |
Select-Object -First 1
}
[pscustomobject]@{
platform = $platform
assetName = $assetName
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
isFullPayload = -not $baselineRelease
}
}
$plan = [pscustomobject]@{
tag = $tag
version = $env:RELEASE_VERSION
channel = $env:RELEASE_CHANNEL
platforms = $entries
}
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
Get-Content plonds-plan.json
- name: Download payload zips
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
}
}
- name: Build delta assets
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
$args = @(
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
'build-delta',
'--platform', $entry.platform,
'--current-version', $plan.version,
'--current-tag', $plan.tag,
'--current-zip', $currentZip,
'--output-dir', 'plonds-output',
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
'--channel', $plan.channel
)
if ([bool]$entry.isFullPayload) {
$args += @('--is-full-payload', 'true')
}
else {
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
}
dotnet @args
}
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
build-index `
--release-tag $plan.tag `
--version $plan.version `
--channel $plan.channel `
--platform-summaries-dir plonds-output/platform-summaries `
--output-dir plonds-output `
--private-key $env:UPDATE_PRIVATE_KEY_PATH
- name: Upload PLONDS assets to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
- name: Persist run metadata
shell: bash
run: |
mkdir -p plonds-run-metadata
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
with:
name: plonds-run-metadata
path: plonds-run-metadata/tag.txt
if-no-files-found: error
retention-days: 7

View File

@@ -1,258 +0,0 @@
name: PLONDS Comparator
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
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
baseline_tag:
description: 'Optional baseline tag (auto-detected if omitted)'
required: false
type: string
channel:
description: 'Update channel'
required: false
type: choice
default: stable
options:
- stable
- preview
compare_method:
description: 'Compare method'
required: false
type: choice
default: file-compare
options:
- file-compare
- commit-analyze
hash_algorithm:
description: 'Hash algorithm (file-compare only)'
required: false
type: choice
default: sha256
options:
- sha256
- md5
env:
DOTNET_VERSION: '10.0.x'
jobs:
compare:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release context
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
CHANNEL="preview"
else
CHANNEL="stable"
fi
BASELINE_TAG_INPUT=""
COMPARE_METHOD="file-compare"
HASH_ALGORITHM="sha256"
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
BASELINE_TAG_INPUT="${{ github.event.inputs.baseline_tag }}"
COMPARE_METHOD="${{ github.event.inputs.compare_method }}"
HASH_ALGORITHM="${{ github.event.inputs.hash_algorithm }}"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG_INPUT}" >> "$GITHUB_ENV"
echo "COMPARE_METHOD=${COMPARE_METHOD}" >> "$GITHUB_ENV"
echo "HASH_ALGORITHM=${HASH_ALGORITHM}" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Resolve baseline
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
BASELINE_TAG=""
BASELINE_VERSION=""
if [[ -n "$BASELINE_TAG_INPUT" ]]; then
NORMALIZED="$BASELINE_TAG_INPUT"
if [[ "$NORMALIZED" != v* ]]; then NORMALIZED="v$NORMALIZED"; fi
if gh release view "$NORMALIZED" --repo "${{ github.repository }}" --json tagName >/dev/null 2>&1; then
BASELINE_TAG="$NORMALIZED"
BASELINE_VERSION="${NORMALIZED#v}"
else
echo "Specified baseline tag not found: $NORMALIZED"
exit 1
fi
else
IS_PRERELEASE="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
CANDIDATES="$(gh api "repos/${{ github.repository }}/releases?per_page=50" \
--jq ".[] | select(.draft == false and .prerelease == ${IS_PRERELEASE} and .tag_name != \"${RELEASE_TAG}\") | .tag_name")"
for CANDIDATE in $CANDIDATES; do
if gh release download "$CANDIDATE" -p "files-windows-x64.zip" -D /tmp/baseline-check --clobber 2>/dev/null; then
BASELINE_TAG="$CANDIDATE"
BASELINE_VERSION="${CANDIDATE#v}"
rm -rf /tmp/baseline-check
break
fi
done
fi
if [[ -n "$BASELINE_TAG" ]]; then
echo "BASELINE_TAG=${BASELINE_TAG}" >> "$GITHUB_ENV"
echo "BASELINE_VERSION=${BASELINE_VERSION}" >> "$GITHUB_ENV"
echo "Resolved baseline: ${BASELINE_TAG}"
else
echo "No baseline found. This will be a full update."
fi
- name: Download payload zips
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p plonds-input
gh release download "$RELEASE_TAG" -p "files-windows-x64.zip" -D plonds-input
mv plonds-input/files-windows-x64.zip plonds-input/current-files-windows-x64.zip
if [[ -n "$BASELINE_TAG" ]]; then
gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D plonds-input
mv plonds-input/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip
fi
- name: Run build-delta (file-compare)
if: env.COMPARE_METHOD == 'file-compare'
shell: bash
run: |
set -euo pipefail
mkdir -p plonds-output
ARGS=(
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
'--configuration' 'Release' '--'
'build-delta'
'--platform' 'windows-x64'
'--current-version' "$RELEASE_VERSION"
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
'--output-dir' "$PWD/plonds-output"
'--channel' "$RELEASE_CHANNEL"
'--hash-algorithm' "$HASH_ALGORITHM"
)
if [[ -n "$BASELINE_TAG" ]]; then
ARGS+=(
'--baseline-version' "$BASELINE_VERSION"
'--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
)
fi
dotnet "${ARGS[@]}"
- name: Run build-delta-from-commits (commit-analyze)
if: env.COMPARE_METHOD == 'commit-analyze'
shell: bash
run: |
set -euo pipefail
mkdir -p plonds-output
ARGS=(
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
'--configuration' 'Release' '--'
'build-delta-from-commits'
'--platform' 'windows-x64'
'--current-version' "$RELEASE_VERSION"
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
'--output-dir' "$PWD/plonds-output"
'--channel' "$RELEASE_CHANNEL"
'--baseline-tag' "${BASELINE_TAG:-$RELEASE_TAG}"
'--current-tag' "$RELEASE_TAG"
'--hash-algorithm' "$HASH_ALGORITHM"
)
if [[ -n "$BASELINE_TAG" ]]; then
ARGS+=(
'--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
)
fi
dotnet "${ARGS[@]}"
- name: Validate output
shell: bash
run: |
set -euo pipefail
if [[ ! -f plonds-output/changed.zip ]]; then
echo "Missing output: changed.zip"
exit 1
fi
if [[ ! -f plonds-output/PLONDS.json ]]; then
echo "Missing output: PLONDS.json"
exit 1
fi
jq -e . plonds-output/PLONDS.json >/dev/null
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/changed.zip plonds-output/PLONDS.json --clobber
- name: Persist run metadata
shell: bash
run: |
mkdir -p plonds-run-metadata
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
printf '%s' "$COMPARE_METHOD" > plonds-run-metadata/compare-method.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
with:
name: plonds-run-metadata
path: |
plonds-run-metadata/tag.txt
plonds-run-metadata/compare-method.txt
if-no-files-found: error
retention-days: 7

View File

@@ -1,146 +0,0 @@
name: PLONDS 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: plonds-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 "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-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 plonds.json plonds.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/plonds-latest.json"
manifest_url="${S3_BASE_URL}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.json.sig"
version="${RELEASE_TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat > "$pointer_file" <<EOF
{
"schemaVersion": 1,
"channel": "${RELEASE_CHANNEL}",
"releaseTag": "${RELEASE_TAG}",
"version": "${version}",
"updatedAt": "${updated_at}",
"manifest": {
"url": "${manifest_url}",
"signatureUrl": "${sig_url}"
}
}
EOF
jq -e . "$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/plonds-latest.json"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-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}/plonds-latest.json"

View File

@@ -1,379 +0,0 @@
name: PLONDS Publisher
concurrency:
group: plonds-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
on:
workflow_run:
workflows:
- PLONDS Comparator
types:
- completed
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
env:
DOTNET_VERSION: '10.0.x'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release tag and channel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "$RAW_TAG" == v* ]]; then
TAG="$RAW_TAG"
else
TAG="v$RAW_TAG"
fi
else
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi
echo "RELEASE_TAG=${TAG}" >> "$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 "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-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
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p release-assets
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 }}
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 --version
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "plonds.json" || "$name" == "plonds.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
if [[ "$existing_sha" == "$sha256" ]]; then
echo "Skip existing asset: $name"
continue
fi
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--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 PLONDS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p plonds-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-plonds \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir plonds-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Validate PLONDS 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' plonds-output/plonds.json \
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
| sort -u)
if [[ -z "$keys" ]]; then
echo "No S3-backed asset URLs found in plonds.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 PLONDS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber
- name: Upload PLONDS manifest to Rainyun S3 staging
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 file in plonds-output/plonds.json plonds-output/plonds.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${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: Prepare PLONDS channel pointer
shell: bash
run: |
set -euo pipefail
pointer_file="plonds-output/plonds-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}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.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 PLONDS 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="plonds-output/plonds-latest.json"
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-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 "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-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

View File

@@ -98,8 +98,10 @@ jobs:
matrix:
include:
- arch: x64
self_contained: true
suffix: ''
- arch: x86
self_contained: true
suffix: ''
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
@@ -165,80 +167,48 @@ jobs:
- name: Publish Main App
run: |
$publishDir = "publish/windows-${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-r win-${{ matrix.arch }} `
-p:SelfContained=false `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:SkipAirAppHostBuild=true `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-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: Publish AirAppRuntime
run: |
$arch = "${{ matrix.arch }}"
$publishDir = "publish/airapp-runtime-win-$arch"
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-r win-$arch `
-p:SelfContained=false `
-p:PublishAot=false `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-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: Publish AirAppHost
run: |
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-r win-$arch `
-p:SelfContained=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 }}
if ($selfContained) {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-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/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-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 }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$launcherPublishDir = "publish/launcher-win-$arch"
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
$appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch"
@@ -250,51 +220,13 @@ jobs:
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
}
if (Test-Path $runtimePublishDir) {
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
}
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -Path $newStructure -Destination $publishDir -Force
shell: pwsh
- name: Optimize and Guard Windows Payload
run: |
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 `
-PublishDir $publishDir `
-RuntimeIdentifier "win-$arch" `
-AssertClean
shell: pwsh
- name: Verify Windows app host payload
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = Join-Path $publishDir "app-$version"
$requiredFiles = @(
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
(Join-Path $appDir "LanMountainDesktop.exe"),
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
)
foreach ($path in $requiredFiles) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
Write-Error "Required release payload file is missing: $path"
exit 1
}
}
shell: pwsh
- name: Install Inno Setup and 7z
run: |
choco install innosetup -y --no-progress
@@ -306,7 +238,8 @@ jobs:
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$suffix = "${{ matrix.suffix }}"
$publishDir = "publish/windows-$arch"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$outputDir = "build-installer"
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
@@ -340,6 +273,7 @@ jobs:
"/DMyOutputDir=$outputDir",
"/DMyAppArch=$arch",
"/DMyAppSuffix=$suffix",
"/DIsSelfContained=$selfContained",
$installerScript
)
@@ -484,7 +418,6 @@ 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 }} \
@@ -492,32 +425,12 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish AirAppRuntime
run: |
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
-c Release \
-o ./publish/airapp-runtime-linux-x64 \
--self-contained false \
-r linux-x64 \
-p:SelfContained=false \
-p:PublishAot=false \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false \
-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 }}
- name: Restructure for Launcher
run: |
version="${{ needs.prepare.outputs.version }}"
publishDir="publish/linux-x64"
appDir="app-$version"
launcherDir="publish/launcher-linux-x64"
runtimeDir="publish/airapp-runtime-linux-x64"
mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir"
@@ -527,13 +440,8 @@ jobs:
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
fi
if [ -d "$runtimeDir" ]; then
cp -r "$runtimeDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
fi
touch "$publishDir/$appDir/.current"
rm -rf "$launcherDir" "$runtimeDir"
rm -rf "$launcherDir"
- name: Package as DEB
run: |
@@ -692,13 +600,12 @@ jobs:
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/macos-${{ matrix.arch }}-app \
--self-contained:false \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:SelfContained=false \
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:SkipAirAppHostBuild=true \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:Version=${{ needs.prepare.outputs.version }} \
@@ -706,36 +613,6 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish AirAppRuntime
run: |
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
-c Release \
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
--self-contained false \
-r osx-${{ matrix.arch }} \
-p:SelfContained=false \
-p:PublishAot=false \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false \
-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 }}
- name: Optimize and Guard macOS Payload
run: |
arch="${{ matrix.arch }}"
publishDir="publish/macos-${arch}-app"
pwsh ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 \
-PublishDir "$publishDir" \
-RuntimeIdentifier "osx-${arch}" \
-AssertClean
shell: bash
- name: Package Payload Zip
run: |
release_dir="$PWD/release-assets"
@@ -758,7 +635,6 @@ jobs:
app_name="LanMountainDesktop"
package_name="${app_name}-${version}-macos-${arch}"
launcherDir="publish/launcher-macos-$arch"
runtimeDir="publish/airapp-runtime-macos-$arch"
appSourceDir="publish/macos-$arch-app"
mkdir -p "${app_name}.app/Contents/MacOS"
@@ -771,11 +647,6 @@ jobs:
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
fi
if [ -d "$runtimeDir" ]; then
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
fi
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources"

4
.gitignore vendored
View File

@@ -6,9 +6,6 @@
# dotenv files
.env
# Local NuGet global packages (NuGet.Config globalPackagesFolder)
.nuget/packages/
# User-specific files
*.rsuser
*.suo
@@ -518,4 +515,3 @@ nul
/velopack-output-local-verify
/velopack-output-local
/test-aot-publish
/.claude/worktrees

View File

@@ -1,403 +0,0 @@
# 课程表组件视觉重构 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 彻底重构阑山桌面的课程表ClassScheduleWidget组件视觉设计参考小爱课程表的桌面小部件风格实现时间轴+色块卡片布局、科目自动配色、当前课程进度高亮等现代化视觉效果。
**Architecture:** 保留现有数据层ClassIslandScheduleDataService、Models和组件注册机制不变仅重构 Widget 的 UI 渲染层XAML + code-behind 中的渲染逻辑)。新增科目配色服务,为每门课程分配稳定的区分色。先创建 HTML Mock 验证视觉效果,再移植到 Avalonia XAML。
**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览)
---
## 当前状态分析
### 现有组件结构
- **XAML**: `ClassScheduleWidget.axaml` — 仅定义了 RootBorder、HeaderGrid日期+星期+课数、ScrollViewer+CourseListPanel、StatusTextBlock
- **Code-behind**: `ClassScheduleWidget.axaml.cs` — 所有课程项 UI 在 `CreateSingleItemControl()` 中手动构建:圆点(Bullet) + 文字栈(课程名/时间/详情)
- **数据层**: `ClassIslandScheduleDataService` + `ClassIslandScheduleModels` — 不变
- **编辑器**: `ClassScheduleComponentEditor.axaml(.cs)` — 不变
### 现有设计问题
1. **视觉单调**: 仅用小圆点区分课程,所有课程外观一致,缺乏层次感
2. **信息密度低**: 课程名、时间、教师名挤在一行,可读性差
3. **当前课不突出**: 仅通过圆点颜色变化标识当前课程,几乎无法一眼识别
4. **色彩硬编码**: 颜色值直接写在 C# 中,不使用语义资源键,不遵循 VISUAL_SPEC
5. **无时间轴感**: 列表式排列无法体现课程的时间先后和持续长度
### 小爱课程表参考设计特征
1. **时间轴布局**: 左侧显示时间刻度,右侧是课程色块卡片
2. **科目配色**: 每门课程自动分配一种柔和区分色,卡片使用对应色块背景
3. **当前课高亮**: 正在进行的课程有明显的视觉强调(放大/进度条/发光)
4. **进度指示**: 当前课程显示上课进度(已过时间/总时长)
5. **紧凑信息**: 课程名+教室/教师信息在色块内清晰排列
6. **课间分隔**: 课间休息区域有视觉分隔(虚线/淡色区域)
---
## 设计方案
### 视觉论文 (Visual Thesis)
时间轴驱动的色块卡片布局,柔和科目配色,当前课程进度高亮——在桌面小组件有限空间内实现信息密度与美感的平衡。
### 布局结构
```
┌─────────────────────────────────────┐
│ 7/24 周一 今天3节课 │ ← 头部:日期 + 星期 + 课数
├─────────────────────────────────────┤
│ 08:00 ┌──────────────────────┐ │
│ │ 语文 │ │ ← 科目色块卡片
│ │ 王老师 · 教室301 │ │
│ 08:45 └──────────────────────┘ │
│ ┌──────────────────────┐ │
│ │ 数学 ████████░░ 75% │ │ ← 当前课:进度条 + 高亮
│ │ 李老师 · 教室205 │ │
│ 09:30 └──────────────────────┘ │
│ ... │
└─────────────────────────────────────┘
```
### 科目配色方案
使用一组预定义的柔和色彩,按科目名哈希值稳定分配:
- 语文: #5B8FF9 (蓝)
- 数学: #F6903D (橙)
- 英语: #5AD8A6 (绿)
- 物理: #E8684A (红)
- 化学: #9270CA (紫)
- 生物: #FF9845 (琥珀)
- 历史: #1E9493 (青)
- 地理: #FF99C3 (粉)
- 政治: #7262FD (靛)
- 体育: #78D3F8 (天蓝)
- 默认: #8B95A5 (灰)
### 当前课程高亮
- 卡片左侧显示 3px 宽的强调色竖条
- 卡片底部显示细进度条(已过时间/总时长)
- 卡片背景使用科目色的 15% 透明度版本
- 非当前课程使用科目色的 8% 透明度版本
---
## 文件变更清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml` | 修改 | 重构 XAML 布局:时间轴+卡片区域 |
| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs` | 修改 | 重构渲染逻辑:色块卡片、科目配色、进度条 |
| `LanMountainDesktop/Views/Components/SubjectColorService.cs` | 新建 | 科目配色服务:稳定哈希分配颜色 |
| `mocks/class-schedule-mock.html` | 新建 | HTML Mock 预览(亮色+暗色) |
---
## Task 分解
### Task 1: 创建 HTML Mock 预览
**Files:**
- Create: `mocks/class-schedule-mock.html`
- [ ] **Step 1: 创建 HTML Mock 文件**
创建完整的 HTML Mock包含
- 亮色/暗色主题切换
- 时间轴+色块卡片布局
- 科目自动配色
- 当前课程进度条高亮
- 课间分隔区域
- 响应式尺寸(模拟桌面组件 2x4 / 4x4 等尺寸)
Mock 中应包含示例数据:
```
08:00-08:45 语文 王老师
08:55-09:40 数学 李老师 (当前课,进度 60%)
09:50-10:35 英语 张老师
10:45-11:30 物理 赵老师
14:00-14:45 化学 陈老师
14:55-15:40 生物 刘老师
```
- [ ] **Step 2: 在浏览器中打开 Mock 验证效果**
Run: `start mocks/class-schedule-mock.html`
- [ ] **Step 3: 根据视觉效果调整 Mock 细节**
调整间距、色值、字体大小、进度条样式等直到满意。
---
### Task 2: 创建科目配色服务
**Files:**
- Create: `LanMountainDesktop/Views/Components/SubjectColorService.cs`
- [ ] **Step 1: 实现 SubjectColorService**
```csharp
using System;
using Avalonia.Media;
namespace LanMountainDesktop.Views.Components;
internal static class SubjectColorService
{
private static readonly (string Name, string Hex)[] Palette = [
("语文", "#5B8FF9"),
("数学", "#F6903D"),
("英语", "#5AD8A6"),
("物理", "#E8684A"),
("化学", "#9270CA"),
("生物", "#FF9845"),
("历史", "#1E9493"),
("地理", "#FF99C3"),
("政治", "#7262FD"),
("体育", "#78D3F8"),
("音乐", "#F25E7E"),
("美术", "#C2A1FD"),
];
private static readonly string DefaultHex = "#8B95A5";
public static Color ResolveColor(string subjectName)
{
foreach (var (name, hex) in Palette)
{
if (subjectName.Contains(name, StringComparison.OrdinalIgnoreCase))
{
return Color.Parse(hex);
}
}
var hash = StableHash(subjectName);
var index = (int)(hash % (uint)Palette.Length);
return Color.Parse(Palette[index].Hex);
}
public static Color ResolveBackgroundColor(string subjectName, bool isCurrent, bool isNight)
{
var baseColor = ResolveColor(subjectName);
var alpha = isCurrent ? 0.18 : 0.08;
return new Color(
(byte)(alpha * 255),
baseColor.R,
baseColor.G,
baseColor.B);
}
public static Color ResolveForegroundColor(string subjectName, bool isNight)
{
var baseColor = ResolveColor(subjectName);
return isNight
? new Color(0xFF, (byte)Math.Min(255, baseColor.R + 60), (byte)Math.Min(255, baseColor.G + 60), (byte)Math.Min(255, baseColor.B + 60))
: baseColor;
}
private static uint StableHash(string input)
{
uint hash = 5381;
foreach (var c in input)
{
hash = ((hash << 5) + hash) ^ (uint)c;
}
return hash;
}
}
```
- [ ] **Step 2: 验证编译通过**
Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug --no-restore`
---
### Task 3: 重构 ClassScheduleWidget XAML 布局
**Files:**
- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml`
- [ ] **Step 1: 重写 XAML 布局**
新的 XAML 结构:
- RootBorder 保持 `DesignCornerRadiusComponent`
- 头部区域:日期(大号)+ 星期 + 课数 + 进度摘要
- 课程列表区域ScrollViewer 包裹 StackPanel
- 每个 CourseItem 将在 code-behind 中构建为Grid(时间列 + 卡片列)
- 时间列StartTime / EndTime 垂直排列
- 卡片列Border(科目色背景) > StackPanel(课程名 + 教师信息 + 进度条)
XAML 只定义骨架,课程项仍由 code-behind 动态构建(因为需要科目配色和进度计算)。
```xml
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="0">
<Grid x:Name="LayoutGrid"
RowDefinitions="Auto,*">
<Grid x:Name="HeaderGrid"
ColumnDefinitions="Auto,*,Auto"
Padding="16,12,16,8">
<StackPanel x:Name="DateGroup"
Orientation="Horizontal"
VerticalAlignment="Center">
<TextBlock x:Name="MonthTextBlock"
FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="SlashTextBlock"
Text="/"
FontWeight="Bold" />
<TextBlock x:Name="DayTextBlock"
FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock x:Name="WeekdayTextBlock"
Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<Border x:Name="ClassCountBadge"
Grid.Column="2"
VerticalAlignment="Center"
Padding="8,3"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}">
<TextBlock x:Name="ClassCountTextBlock"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
<ScrollViewer x:Name="ContentScrollViewer"
Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="CourseListPanel"
Spacing="4" />
</ScrollViewer>
<TextBlock x:Name="StatusTextBlock"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
IsVisible="False"
TextWrapping="Wrap" />
</Grid>
</Border>
</UserControl>
```
---
### Task 4: 重构 ClassScheduleWidget 渲染逻辑
**Files:**
- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs`
- [ ] **Step 1: 扩展 CourseItemViewModel**
在现有 record 中增加字段:
```csharp
private sealed record CourseItemViewModel(
string Name,
string TimeRange,
string Detail,
bool IsCurrent,
TimeSpan StartTime,
TimeSpan EndTime,
double Progress);
```
- [ ] **Step 2: 修改 BuildCourseItemViewModels 计算进度**
在构建 ViewModel 时,对当前课程计算 Progress = (now - startTime) / (endTime - startTime)。
- [ ] **Step 3: 重写 CreateSingleItemControl**
新的课程项 UI 结构:
```
Grid (2列: 时间列 Auto + 卡片列 *)
├── StackPanel (时间列)
│ ├── TextBlock (开始时间, 如 "08:00")
│ └── TextBlock (结束时间, 如 "08:45", 较淡)
└── Border (卡片列, 科目色背景, 圆角 DesignCornerRadiusSm)
├── 左侧强调竖条 (当前课显示, 3px宽, 科目色)
└── StackPanel
├── TextBlock (课程名, 科目色前景, 加粗)
├── TextBlock (教师/教室, 次要色)
└── ProgressBar (当前课显示, 科目色)
```
关键改动点:
1. 移除圆点(Bullet),改用时间轴左侧时间标签
2. 课程卡片使用 `SubjectColorService` 配色
3. 当前课程卡片左侧显示强调竖条 + 底部进度条
4. 课间区域用淡色分隔线标识
5. 颜色使用语义资源键(`AdaptiveTextPrimaryBrush` 等),科目色通过 `SubjectColorService` 获取
- [ ] **Step 4: 重写 ApplyAdaptiveLayout**
更新自适应布局逻辑:
- 头部日期/星期/课数徽章的字号和间距
- 移除旧的圆点、文字栈相关计算
- 新增时间列宽度、卡片圆角、进度条高度等计算
- 使用 `ComponentChromeCornerRadiusHelper` 获取圆角 Token
- [ ] **Step 5: 更新 IncrementalUpdateItems 和 IncrementalUpdateCurrentCourseHighlight**
适配新的 UI 结构:
- 更新进度条值
- 更新科目色背景
- 更新强调竖条可见性
- [ ] **Step 6: 更新 RefreshSchedule 中的时间计算**
`BuildCourseItemViewModels` 中传入 `StartTime`/`EndTime`/`Progress`
- [ ] **Step 7: 验证编译通过**
Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug`
---
### Task 5: 验证与测试
- [ ] **Step 1: 运行项目查看效果**
Run: `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`
- [ ] **Step 2: 运行相关测试**
Run: `dotnet test LanMountainDesktop.slnx -c Debug`
- [ ] **Step 3: 检查圆角规范合规**
确认 RootBorder 使用 `DesignCornerRadiusComponent`,内部卡片使用 `DesignCornerRadiusSm`/`DesignCornerRadiusMd`,无硬编码圆角值。
---
## 假设与决策
1. **科目配色**: 使用预定义调色板 + 哈希回退,不依赖 ClassIsland 数据中的科目颜色(因为 ClassIsland 不提供科目颜色字段)
2. **进度条**: 仅当前课程显示进度条,非当前课程不显示
3. **课间分隔**: 用 4px 间距 + 可选的淡色虚线分隔,不做复杂的课间休息区域
4. **Mock 优先**: 先完成 HTML Mock 确认视觉效果,再实现 Avalonia 代码
5. **编辑器不变**: ClassScheduleComponentEditor 不需要修改
6. **数据层不变**: ClassIslandScheduleDataService 和 Models 不需要修改
7. **接口兼容**: IDesktopComponentWidget、ITimeZoneAwareComponentWidget、IComponentPlacementContextAware 接口实现不变
## 验证步骤
1. HTML Mock 在浏览器中展示效果满意
2. Avalonia 项目编译通过
3. 运行项目,课程表组件显示新布局
4. 亮色/暗色主题切换正常
5. 当前课程高亮和进度条正常
6. 科目配色稳定(同一科目每次显示颜色一致)
7. 测试通过

View File

@@ -1,850 +0,0 @@
# 启动器 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` 中添加 `<EmbeddedResource>` 确保资源不被修剪
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
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" use="optional" />
<xsd:attribute name="mimetype" type="xsd:string" use="optional" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms</value></resheader>
<!-- SplashWindow -->
<data name="Splash_Title" xml:space="preserve"><value>阑山桌面</value></data>
<data name="Splash_AppName" xml:space="preserve"><value>阑山桌面</value></data>
<data name="Splash_StatusInitializing" xml:space="preserve"><value>正在初始化...</value></data>
<data name="Splash_DebugPreview" xml:space="preserve"><value>[调试模式] 启动画面预览</value></data>
<!-- ErrorWindow -->
<data name="Error_Title" xml:space="preserve"><value>阑山桌面</value></data>
<data name="Error_TitleCannotConfirm" xml:space="preserve"><value>启动器无法确认启动状态</value></data>
<data name="Error_MessageNotReached" xml:space="preserve"><value>阑山桌面未达到预期的启动状态。</value></data>
<data name="Error_SuggestionTitle" xml:space="preserve"><value>启动恢复</value></data>
<data name="Error_SuggestionMessage" xml:space="preserve"><value>您可以检查日志、等待当前进程或激活正在运行的桌面实例。</value></data>
<data name="Error_DiagnosticHeader" xml:space="preserve"><value>诊断详情</value></data>
<data name="Error_ButtonOpenLogs" xml:space="preserve"><value>打开日志</value></data>
<data name="Error_ButtonCopy" xml:space="preserve"><value>复制</value></data>
<data name="Error_ButtonWait" xml:space="preserve"><value>等待</value></data>
<data name="Error_ButtonExit" xml:space="preserve"><value>退出</value></data>
<data name="Error_ButtonRetry" xml:space="preserve"><value>重试</value></data>
<data name="Error_ButtonActivate" xml:space="preserve"><value>激活</value></data>
<data name="Error_DebugTitle" xml:space="preserve"><value>[调试] 启动器错误</value></data>
<data name="Error_HostNotFoundTitle" xml:space="preserve"><value>启动器找不到桌面可执行文件</value></data>
<data name="Error_HostNotFoundMessage" xml:space="preserve"><value>在调试模式下选择另一个可执行文件、检查日志,或在修复部署路径后重试。</value></data>
<data name="Error_GenericMessage" xml:space="preserve"><value>检查日志后重试,等待上一次启动尝试完全结束。</value></data>
<data name="Error_RunningHostMessage" xml:space="preserve"><value>检查日志或退出。旧进程仍在运行时,启动器不会创建新的桌面进程。</value></data>
<data name="Error_PendingTitle" xml:space="preserve"><value>启动仍在进行中</value></data>
<data name="Error_PendingMessage" xml:space="preserve"><value>桌面进程仍在运行,启动器不会启动第二个实例。</value></data>
<!-- MultiInstancePromptWindow -->
<data name="MultiInstance_Title" xml:space="preserve"><value>阑山桌面</value></data>
<data name="MultiInstance_AlreadyRunning" xml:space="preserve"><value>阑山桌面已在运行</value></data>
<data name="MultiInstance_AlreadyRunningMessage" xml:space="preserve"><value>启动器检测到已存在的桌面实例,未启动新进程。</value></data>
<data name="MultiInstance_RepeatedLaunchTitle" xml:space="preserve"><value>重复启动</value></data>
<data name="MultiInstance_RepeatedLaunchMessage" xml:space="preserve"><value>您当前的设置为显示此提示而不自动打开桌面。</value></data>
<data name="MultiInstance_NoSecondProcess" xml:space="preserve"><value>未创建第二个主进程。</value></data>
<data name="MultiInstance_ButtonCopy" xml:space="preserve"><value>复制</value></data>
<data name="MultiInstance_ButtonClose" xml:space="preserve"><value>关闭</value></data>
<data name="MultiInstance_ButtonOpenDesktop" xml:space="preserve"><value>打开桌面</value></data>
<data name="MultiInstance_DetailsFormat" xml:space="preserve"><value>现有主进程 PID: {0}\nShell 状态: {1}\n未创建第二个主进程。</value></data>
<!-- DataLocationPromptWindow -->
<data name="DataLocation_Title" xml:space="preserve"><value>选择数据保存位置</value></data>
<data name="DataLocation_ChooseLocation" xml:space="preserve"><value>选择数据保存位置</value></data>
<data name="DataLocation_ChooseLocationDesc" xml:space="preserve"><value>选择启动器和桌面数据的存储位置。您可以稍后在设置中更改。</value></data>
<data name="DataLocation_NotWritable" xml:space="preserve"><value>应用目录不可写入</value></data>
<data name="DataLocation_NotWritableDesc" xml:space="preserve"><value>当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。</value></data>
<data name="DataLocation_SystemProfile" xml:space="preserve"><value>保存在系统用户目录(推荐)</value></data>
<data name="DataLocation_SystemProfileDesc" xml:space="preserve"><value>数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。</value></data>
<data name="DataLocation_Portable" xml:space="preserve"><value>保存在应用安装目录(便携模式)</value></data>
<data name="DataLocation_PortableDesc" xml:space="preserve"><value>适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。</value></data>
<data name="DataLocation_ButtonCancel" xml:space="preserve"><value>取消</value></data>
<data name="DataLocation_ButtonConfirm" xml:space="preserve"><value>确认</value></data>
<data name="DataLocation_MigrateWarning" xml:space="preserve"><value>检测到已有的系统数据。选择便携模式将自动迁移当前数据。</value></data>
<!-- LoadingDetailsWindow -->
<data name="Loading_Title" xml:space="preserve"><value>阑山桌面 - 加载详情</value></data>
<data name="Loading_StartingDesktop" xml:space="preserve"><value>正在启动阑山桌面</value></data>
<data name="Loading_StatusInitializing" xml:space="preserve"><value>正在初始化...</value></data>
<data name="Loading_StatusPreparing" xml:space="preserve"><value>正在准备组件</value></data>
<data name="Loading_LoadingItems" xml:space="preserve"><value>加载项目</value></data>
<data name="Loading_Done" xml:space="preserve"><value>完成</value></data>
<data name="Loading_ErrorOccurred" xml:space="preserve"><value>加载时发生错误。</value></data>
<data name="Loading_ButtonDetails" xml:space="preserve"><value>详情</value></data>
<data name="Loading_ButtonCancel" xml:space="preserve"><value>取消</value></data>
<data name="Loading_StageReady" xml:space="preserve"><value>准备就绪</value></data>
<data name="Loading_ItemPlugin" xml:space="preserve"><value>正在加载插件...</value></data>
<data name="Loading_ItemComponent" xml:space="preserve"><value>正在加载组件...</value></data>
<data name="Loading_ItemResource" xml:space="preserve"><value>正在加载资源...</value></data>
<data name="Loading_ItemData" xml:space="preserve"><value>正在加载数据...</value></data>
<data name="Loading_ItemDownload" xml:space="preserve"><value>正在下载...</value></data>
<data name="Loading_ItemProcess" xml:space="preserve"><value>正在处理...</value></data>
<data name="Loading_ItemComplete" xml:space="preserve"><value>完成</value></data>
<data name="Loading_TypePlugin" xml:space="preserve"><value>插件</value></data>
<data name="Loading_TypeComponent" xml:space="preserve"><value>组件</value></data>
<data name="Loading_TypeResource" xml:space="preserve"><value>资源</value></data>
<data name="Loading_TypeData" xml:space="preserve"><value>数据</value></data>
<data name="Loading_TypeNetwork" xml:space="preserve"><value>网络</value></data>
<data name="Loading_TypeSettings" xml:space="preserve"><value>设置</value></data>
<data name="Loading_TypeSystem" xml:space="preserve"><value>系统</value></data>
<data name="Loading_TypeOther" xml:space="preserve"><value>其他</value></data>
<!-- UpdateWindow -->
<data name="Update_Title" xml:space="preserve"><value>阑山桌面 - 更新</value></data>
<data name="Update_AppName" xml:space="preserve"><value>阑山桌面</value></data>
<data name="Update_StatusUpdate" xml:space="preserve"><value>更新</value></data>
<data name="Update_StatusUpdating" xml:space="preserve"><value>正在更新,请稍候...</value></data>
<data name="Update_Complete" xml:space="preserve"><value>更新完成</value></data>
<data name="Update_Failed" xml:space="preserve"><value>更新失败</value></data>
<data name="Update_FailedMessage" xml:space="preserve"><value>更新过程中发生错误</value></data>
<data name="Update_DebugTitle" xml:space="preserve"><value>[调试模式] 更新页面</value></data>
<data name="Update_DebugMessage" xml:space="preserve"><value>预览更新进度界面</value></data>
<!-- ErrorDebugWindow -->
<data name="DebugDebug_Title" xml:space="preserve"><value>调试模式</value></data>
<data name="DebugDebug_SettingsTitle" xml:space="preserve"><value>调试设置</value></data>
<data name="DebugDebug_DevMode" xml:space="preserve"><value>开发模式</value></data>
<data name="DebugDebug_DevModeDesc" xml:space="preserve"><value>启用后自动扫描开发目录</value></data>
<data name="DebugDebug_On" xml:space="preserve"><value></value></data>
<data name="DebugDebug_Off" xml:space="preserve"><value></value></data>
<data name="DebugDebug_AppPath" xml:space="preserve"><value>应用路径</value></data>
<data name="DebugDebug_NotSelected" xml:space="preserve"><value>未选择</value></data>
<data name="DebugDebug_Browse" xml:space="preserve"><value>浏览...</value></data>
<data name="DebugDebug_Warning" xml:space="preserve"><value>此功能仅供开发人员使用</value></data>
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>取消</value></data>
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>确定</value></data>
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>选择阑山桌面主程序可执行文件</value></data>
<!-- OobeWindow -->
<data name="Oobe_Title" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>你的桌面,不止一面</value></data>
<data name="Oobe_ButtonGetStarted" xml:space="preserve"><value>开始使用</value></data>
<data name="Oobe_AppearanceTitle" xml:space="preserve"><value>个性化你的桌面</value></data>
<data name="Oobe_AppearanceDesc" xml:space="preserve"><value>选择你喜欢的主题样式,可随时在设置中更改</value></data>
<data name="Oobe_AppearanceMode" xml:space="preserve"><value>外观模式</value></data>
<data name="Oobe_LightMode" xml:space="preserve"><value>浅色模式</value></data>
<data name="Oobe_DarkMode" xml:space="preserve"><value>深色模式</value></data>
<data name="Oobe_ThemeColor" xml:space="preserve"><value>主题色</value></data>
<data name="Oobe_MonetSource" xml:space="preserve"><value>莫奈取色来源</value></data>
<data name="Oobe_MonetFromWallpaper" xml:space="preserve"><value>从桌面壁纸取色</value></data>
<data name="Oobe_MonetFromCustomImage" xml:space="preserve"><value>自定义图片取色</value></data>
<data name="Oobe_MonetDisabled" xml:space="preserve"><value>不使用莫奈取色</value></data>
<data name="Oobe_DataLocationTitle" xml:space="preserve"><value>选择数据保存位置</value></data>
<data name="Oobe_SystemProfile" xml:space="preserve"><value>保存在系统用户目录(推荐)</value></data>
<data name="Oobe_SystemProfileDesc" xml:space="preserve"><value>数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。</value></data>
<data name="Oobe_Portable" xml:space="preserve"><value>保存在应用安装目录(便携模式)</value></data>
<data name="Oobe_PortableDesc" xml:space="preserve"><value>适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。</value></data>
<data name="Oobe_NotWritable" xml:space="preserve"><value>无法保存到应用目录</value></data>
<data name="Oobe_NotWritableDesc" xml:space="preserve"><value>当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。</value></data>
<data name="Oobe_StartupTitle" xml:space="preserve"><value>启动与展示</value></data>
<data name="Oobe_ShowInTaskbar" xml:space="preserve"><value>在任务栏显示主桌面窗口</value></data>
<data name="Oobe_SlideTransition" xml:space="preserve"><value>以滑动方式显示主窗口</value></data>
<data name="Oobe_FadeTransition" xml:space="preserve"><value>启动时使用淡入过渡</value></data>
<data name="Oobe_FusedDesktop" xml:space="preserve"><value>融合桌面与弹入手势</value></data>
<data name="Oobe_AutoStart" xml:space="preserve"><value>登录 Windows 时自动启动阑山桌面</value></data>
<data name="Oobe_PrivacyTitle" xml:space="preserve"><value>信息与隐私</value></data>
<data name="Oobe_CrashReports" xml:space="preserve"><value>发送匿名崩溃报告</value></data>
<data name="Oobe_UsageStats" xml:space="preserve"><value>发送匿名使用统计</value></data>
<data name="Oobe_PrivacyTrackingId" xml:space="preserve"><value>隐私追踪 ID</value></data>
<data name="Oobe_Agree" xml:space="preserve"><value>同意</value></data>
<data name="Oobe_PrivacyPolicyLink" xml:space="preserve"><value>《阑山桌面遥测隐私数据收集协议》</value></data>
<data name="Oobe_ButtonBack" xml:space="preserve"><value>返回</value></data>
<data name="Oobe_ButtonNext" xml:space="preserve"><value>下一步</value></data>
<data name="Oobe_CompleteTitle" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
<data name="Oobe_CompleteSubtitle" xml:space="preserve"><value>你的桌面,不止一面</value></data>
<!-- MigrationPromptWindow -->
<data name="Migration_Title" xml:space="preserve"><value>阑山桌面 - 版本迁移</value></data>
<data name="Migration_DetectedOldVersion" xml:space="preserve"><value>检测到旧版本</value></data>
<data name="Migration_DetectedDesc" xml:space="preserve"><value>检测到您的系统中安装了旧版本的阑山桌面0.8.4...</value></data>
<data name="Migration_Version" xml:space="preserve"><value>版本:</value></data>
<data name="Migration_Location" xml:space="preserve"><value>位置:</value></data>
<data name="Migration_Type" xml:space="preserve"><value>类型:</value></data>
<data name="Migration_Installed" xml:space="preserve"><value>安装版</value></data>
<data name="Migration_UninstallNote" xml:space="preserve"><value>卸载旧版本不会影响新版本的使用,您的个人数据将保留。</value></data>
<data name="Migration_ButtonViewLocation" xml:space="preserve"><value>查看位置</value></data>
<data name="Migration_ButtonSkip" xml:space="preserve"><value>暂不处理</value></data>
<data name="Migration_ButtonUninstall" xml:space="preserve"><value>卸载旧版本</value></data>
<!-- PrivacyPolicyWindow -->
<data name="Privacy_Title" xml:space="preserve"><value>阑山桌面遥测隐私数据收集协议</value></data>
<data name="Privacy_Header" xml:space="preserve"><value>阑山桌面遥测隐私数据收集协议</value></data>
<data name="Privacy_Description" xml:space="preserve"><value>请仔细阅读以下协议内容,了解我们如何收集、使用和保护您的数据</value></data>
<data name="Privacy_ButtonClose" xml:space="preserve"><value>关闭</value></data>
<!-- DevDebugWindow -->
<data name="DevDebug_Title" xml:space="preserve"><value>开发调试窗口</value></data>
<data name="DevDebug_Splash" xml:space="preserve"><value>启动画面</value></data>
<data name="DevDebug_Error" xml:space="preserve"><value>错误页面</value></data>
<data name="DevDebug_Update" xml:space="preserve"><value>更新页面</value></data>
<data name="DevDebug_Oobe" xml:space="preserve"><value>OOBE页面</value></data>
<data name="DevDebug_DataLocation" xml:space="preserve"><value>数据位置选择</value></data>
<data name="DevDebug_EnableFeature" xml:space="preserve"><value>启用功能</value></data>
<data name="DevDebug_Open" xml:space="preserve"><value>打开</value></data>
<data name="DevDebug_SetAllViewMode" xml:space="preserve"><value>全部设为查看模式</value></data>
<data name="DevDebug_SetAllFunctionMode" xml:space="preserve"><value>全部设为功能模式</value></data>
<data name="DevDebug_Close" xml:space="preserve"><value>关闭</value></data>
<!-- LauncherFlowCoordinator -->
<data name="Coordinator_SlowDeviceMessage" xml:space="preserve"><value>设备较慢,仍在启动,请稍候。</value></data>
<data name="Coordinator_RunningHostMessage" xml:space="preserve"><value>桌面主进程仍在运行Launcher 会继续等待,不会重复启动。</value></data>
<!-- App.axaml.cs preview strings -->
<data name="Preview_SplashInitializing" xml:space="preserve"><value>正在初始化...</value></data>
<data name="Preview_SplashCheckingUpdates" xml:space="preserve"><value>正在检查更新...</value></data>
<data name="Preview_SplashCheckingPlugins" xml:space="preserve"><value>正在检查插件...</value></data>
<data name="Preview_SplashLaunchingHost" xml:space="preserve"><value>正在启动主程序...</value></data>
<data name="Preview_SplashReady" xml:space="preserve"><value>准备就绪</value></data>
<data name="Preview_ErrorMessage" xml:space="preserve"><value>[预览] 这是启动器错误窗口预览。</value></data>
<data name="Preview_UpdateProcessing" xml:space="preserve"><value>正在处理 {0}...</value></data>
<data name="Preview_ActivationConnecting" xml:space="preserve"><value>正在连接到活跃的启动器...</value></data>
</root>
```
- [ ] **Step 2: 创建 en-US RESX 文件**
创建 `Resources/Strings.en-US.resx`,包含所有字符串的英文翻译。结构与默认文件相同,仅 `<value>` 内容为英文。
```xml
<!-- 示例条目 -->
<data name="Splash_Title" xml:space="preserve"><value>LanMountain Desktop</value></data>
<data name="Splash_AppName" xml:space="preserve"><value>LanMountain Desktop</value></data>
<data name="Splash_StatusInitializing" xml:space="preserve"><value>Initializing...</value></data>
<data name="Error_TitleCannotConfirm" xml:space="preserve"><value>Launcher could not confirm startup</value></data>
<data name="Error_MessageNotReached" xml:space="preserve"><value>LanMountain Desktop did not reach the expected startup state.</value></data>
<!-- ... 所有键的英文翻译 ... -->
```
- [ ] **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``<ItemGroup>` 中添加:
```xml
<ItemGroup>
<EmbeddedResource Update="Resources\Strings.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Strings.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
```
注意:使用 `PublicResXFileCodeGenerator` 而非 `ResXFileCodeGenerator`,生成 `public` 类以便 AXAML 的 `x:Static` 可以访问。
- [ ] **Step 6: 修改 AOT props 添加资源程序集保留**
`LanMountainDesktop.Launcher.AOT.props` 的 AOT 修剪配置 `<ItemGroup>` 中添加:
```xml
<TrimmerRootAssembly Include="LanMountainDesktop.Launcher" />
```
- [ ] **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<string>(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 命名空间并替换字符串**
`<Window>` 标签添加命名空间:
```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# 中或使用独立资源文件。

View File

@@ -154,7 +154,7 @@
│ │
│ 方案 2: 命名管道(推荐用于进度报告) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ [历史方案] Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
│ │ 主程序连接并发送进度消息 │ │
│ │ │ │
│ │ 消息格式: JSON │ │
@@ -289,7 +289,7 @@ public static class LauncherIpcConstants
#### 4. 实现 IPC 服务端
**历史方案,已废弃**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
```csharp
using System.IO.Pipes;
@@ -428,7 +428,7 @@ public async Task<LauncherResult> RunAsync()
#### 6. 实现 IPC 客户端
**历史方案,已废弃**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
```csharp
using System.IO.Pipes;
@@ -672,8 +672,8 @@ public class UpdateInstallationService
### 新增文件
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - 历史启动进度 IPC 服务端,已由公共 IPC 通知替代
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - 历史启动进度 IPC 客户端,已由公共 IPC 通知替代
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
### 删除文件
@@ -715,11 +715,3 @@ public class UpdateInstallationService
- [ ] GitHub Actions 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常
## 2026 Multi-instance Policy Update
- The old launcher progress pipe is historical only; current startup progress uses public IPC.
- Launcher now reads Host `settings.json` for `MultiInstanceLaunchBehavior` before normal launch.
- Existing Host behavior is policy-driven: restart app, open desktop silently, prompt only, or notify and open desktop.
- Host no longer owns the single-instance listener or already-running prompt; repeated-launch policy lives in Launcher.
- The repeated-launch prompt is a Fluent Launcher window; Host public IPC only exposes execution actions such as activate, restart, and exit.

View File

@@ -1,212 +0,0 @@
# 更新设置界面重设计实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将更新设置页面从丑陋的卡片堆叠布局重设计为遵循 Fluent Design 的 FASettingsExpander 列表布局,与项目其他设置页面保持视觉一致性。
**Architecture:** 移除所有 `Border.settings-section-card` 包裹,改用 `FASettingsExpander` + `IconText` 分节标题 + `Separator` 分隔线的统一模式。操作按钮改为仅显示当前可用操作。版本信息改为 `FASettingsExpanderItem` 行项目展示。ViewModel 层新增 `ActiveActions` 计算属性来驱动按钮可见性。
**Tech Stack:** Avalonia UI 11, FluentAvalonia 2.x, CommunityToolkit.Mvvm
---
## 当前状态分析
### 现有文件
| 文件 | 职责 |
|------|------|
| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` | 更新页面 AXAML 布局 |
| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs` | 代码隐藏 |
| `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` | 视图模型 |
| `LanMountainDesktop/Styles/SettingsCardStyles.axaml` | 通用设置样式 |
| `LanMountainDesktop/Controls/IconText.axaml(.cs)` | 分节标题控件 |
| `LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs` | UpdatePhase 枚举和扩展方法 |
### 核心问题
1. **4 个 `Border.settings-section-card` 卡片**:状态卡、版本信息卡、进度卡、操作卡,每个都带边框+阴影+圆角,视觉零碎
2. **FAInfoBar 嵌套在卡片内**:冗余的容器层级
3. **7 个按钮 3×3 网格**:大量按钮在当前阶段不可用但仍然占据空间
4. **与其他设置页面风格不一致**GeneralSettingsPage、AppearanceSettingsPage 等全部使用 `FASettingsExpander` 列表
### 参考基准
- [GeneralSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml)`IconText` 分节标题 → `FASettingsExpander` 列表 → `Separator` 分隔
- [AppearanceSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml):同上模式
- [AboutSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml)`FAInfoBar` 用于静态信息展示
- Windows 11 设置 > Windows Update顶部状态区 + 进度条 + 操作按钮,下方展开区展示详情
---
## 设计决策
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 布局模式 | FASettingsExpander 列表 | 与其他设置页面统一Fluent Design 原生控件 |
| 按钮策略 | 仅显示可用操作 | 简洁、不混乱Windows 11 更新页面也是此模式 |
| 版本信息 | FASettingsExpanderItem 行项目 | 每行一个信息,干净可扫描 |
| 进度展示 | 内嵌在状态 Expander 内 | 进度是状态的一部分,不应独立成卡 |
| 偏好设置 | 保留 FASettingsExpander | 已经是正确模式,微调即可 |
---
## 新布局结构
```
ScrollViewer
└── StackPanel (settings-page-container settings-page-animated)
├── TextBlock (settings-section-title: "更新")
├── TextBlock (settings-section-description: 描述文字)
├── IconText (Icon="ArrowSync", Text="更新状态")
├── FASettingsExpander "检查更新" (IsClickEnabled=True, Command=CheckCommand)
│ ├── IconSource: ArrowSync 图标
│ └── Footer: Button "检查更新" (仅 CanCheck 时可见)
├── FASettingsExpander "更新进度" (IsVisible=IsBusy||IsProgressVisible||IsPaused)
│ ├── IconSource: FAProgressRing / 对应阶段图标
│ ├── Footer: PhaseText + ProgressFraction
│ └── FASettingsExpanderItem
│ ├── ProgressBar (ProgressFraction)
│ ├── ProgressDetail 文字
│ └── 操作按钮行 (仅可用操作)
│ ├── Button "下载" (CanDownload)
│ ├── Button "安装" (CanInstall)
│ ├── Button "暂停" (CanPause)
│ ├── Button "继续" (CanResume)
│ ├── Button "回滚" (CanRollback)
│ └── Button "取消" (CanCancel)
├── FASettingsExpander "暂停" (IsVisible=IsPaused)
│ └── FAInfoBar (PausedBadgeText + PausedHintText)
├── Separator (settings-separator)
├── IconText (Icon="Info", Text="版本信息")
├── FASettingsExpander "当前版本" (IsClickEnabled=False)
│ ├── IconSource: 版本图标
│ └── Footer: CurrentVersionText
├── FASettingsExpander "最新版本" (IsClickEnabled=False)
│ ├── IconSource: 下载图标
│ └── Footer: LatestVersionText (或 "已是最新")
├── FASettingsExpander "发布时间" (IsClickEnabled=False)
│ ├── IconSource: 日历图标
│ └── Footer: PublishedAtText
├── FASettingsExpander "上次检查" (IsClickEnabled=False)
│ ├── IconSource: 时钟图标
│ └── Footer: LastCheckedText
├── FASettingsExpander "更新类型" (IsClickEnabled=False)
│ ├── IconSource: 标签图标
│ └── Footer: UpdateTypeText
├── Separator (settings-separator)
├── IconText (Icon="Settings", Text="更新偏好")
└── FASettingsExpander "更新偏好" (IsExpanded=True)
├── IconSource: 设置齿轮图标
├── FASettingsExpanderItem "更新频道"
│ └── Footer: ComboBox (stable/preview)
├── FASettingsExpanderItem "下载源"
│ └── Footer: ComboBox (plonds/github/proxy)
├── FASettingsExpanderItem "更新模式"
│ └── Footer: ComboBox (manual/confirm/silent)
└── FASettingsExpanderItem "下载线程数"
└── Footer: Slider + TextBlock
```
---
## Proposed Changes
### Task 1: 重写 UpdateSettingsPage.axaml 布局
**Files:**
- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml`
**What:** 完全重写 AXAML将 4 个 `Border.settings-section-card` 替换为 `FASettingsExpander` 列表布局。
**Key changes:**
1. 移除所有 `Border.settings-section-card` 包裹
2. 使用 `controls:IconText` 做分节标题(与 GeneralSettingsPage 一致)
3. 状态区域:`FASettingsExpander` + `IsClickEnabled=True` + `Command=CheckCommand`Footer 放检查按钮
4. 进度区域:`FASettingsExpander` 内嵌 ProgressBar + 操作按钮,仅 `IsBusy||IsProgressVisible||IsPaused` 时可见
5. 版本信息:每个字段一个 `FASettingsExpander`Footer 直接显示值(参考 Windows 11 更新页面的行项目模式)
6. 偏好设置:保留 `FASettingsExpander` + `FASettingsExpanderItem` 模式,但将 TextBox 改为 ComboBox更符合 Fluent 规范)
7. 使用 `Separator classes="settings-separator"` 分隔三大区域
**Why:** 与项目其他设置页面统一风格,遵循 Fluent Design消除卡片堆叠的视觉噪音。
**How:**
- 参照 GeneralSettingsPage.axaml 的布局模式
- 参照 AppearanceSettingsPage.axaml 的 FASettingsExpander 使用方式
- 参照 AboutSettingsPage.axaml 的 FAInfoBar 使用方式
### Task 2: 更新 ViewModel — 添加 ComboBox 数据源和按钮可见性属性
**Files:**
- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs`
**What:**
1. 将更新频道、下载源、更新模式从 `TextBox` 绑定改为 `ComboBox` 绑定,添加 `ObservableCollection<SelectionOption>` 类型的数据源属性
2. 添加 `IsProgressSectionVisible` 计算属性(`IsBusy || IsProgressVisible || IsPaused`
3. 添加 `IsUpdateAvailableSectionVisible` 计算属性(`IsUpdateAvailable`
4. 添加 `IsStatusInfoVisible` 计算属性(有 StatusMessage 且非空闲时)
5. 移除不再需要的独立按钮文本属性CheckButtonText 保留,其他按钮文本属性保留但仅在可见时使用)
**Why:** ComboBox 比 TextBox 更适合有限选项的输入,且与 GeneralSettingsPage 的模式一致。按钮可见性属性让 AXAML 可以用 `IsVisible` 绑定控制按钮显示。
**How:**
- 参考 GeneralSettingsPageViewModel 中 SelectionOption 的使用方式
-`OnCurrentPhaseChanged` 中触发新属性的 OnPropertyChanged
### Task 3: 将偏好设置 TextBox 替换为 ComboBox
**Files:**
- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` (在 Task 1 中一并完成)
- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` (在 Task 2 中一并完成)
**What:** 将更新频道、下载源、更新模式三个 `TextBox` 替换为 `ComboBox`,使用 `SelectionOption` 数据模板。
**Why:** 有限选项应使用 ComboBox 而非自由文本输入,这是 Fluent Design 的基本规范,也与 GeneralSettingsPage 中的语言/时区选择一致。
### Task 4: 构建验证
**Files:**
- 无新文件
**What:** 运行 `dotnet build` 确保编译通过,检查 AXAML 绑定是否正确。
---
## Assumptions & Decisions
1. **不修改 UpdateOrchestrator 和 UpdateState** — 只改 UI 层和 ViewModel 的展示逻辑,不改底层更新引擎
2. **不修改 SettingsCardStyles.axaml** — 通用样式保持不变,移除的是 UpdateSettingsPage 对它的使用
3. **保留所有 ViewModel 属性** — 即使某些属性在新布局中不再直接使用(如独立的 ActionsTitle也保留以避免破坏本地化系统
4. **ComboBox 选项硬编码在 ViewModel** — 参考 GeneralSettingsPageViewModel 的 SelectionOption 模式
5. **进度区域在空闲时隐藏** — 不显示空的进度条,只在有活动时展示
6. **FAInfoBar 仅用于暂停/错误提示** — 不再嵌套在卡片内,直接放在 FASettingsExpanderItem 内
---
## Verification Steps
1. `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过
2. 运行应用,导航到设置 > 更新页面,验证:
- 页面布局与 GeneralSettingsPage 风格一致
- 无圆角矩形卡片包裹
- 检查更新按钮可用
- 进度区域在空闲时隐藏
- 版本信息以行项目形式展示
- 偏好设置使用 ComboBox
- 操作按钮仅显示当前可用的
3. 点击「检查更新」,验证状态变化和进度展示
4. 验证偏好设置的 ComboBox 选择能正确保存和加载

View File

@@ -1,559 +0,0 @@
# 天气组件 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
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid>
<components:MaterialWeatherSceneControl x:Name="Scene" />
<Border x:Name="OverlayBorder" />
<!-- 主内容区 -->
<Grid x:Name="ContentGrid"
RowDefinitions="*,Auto"
Margin="20,16,20,14">
<!-- 上半区:温度 + 图标 -->
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="4">
<!-- 温度:超大字体 -->
<TextBlock x:Name="TemperatureTextBlock"
Text="--°"
FontSize="72"
FontWeight="Bold"
LineHeight="72" />
<!-- 天气状况 -->
<TextBlock x:Name="ConditionTextBlock"
Text="Loading"
FontSize="18"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- 右侧图标 -->
<components:WeatherIconView x:Name="MainIcon"
Grid.Column="1"
Width="72"
Height="72"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
<!-- 底部信息栏 -->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
<TextBlock x:Name="LocationTextBlock"
Text="Weather"
FontSize="13"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Bottom" />
<TextBlock x:Name="RangeTextBlock"
Grid.Column="1"
Text="-- / --"
FontSize="13"
FontWeight="Medium"
HorizontalAlignment="Right"
VerticalAlignment="Bottom" />
</Grid>
</Grid>
</Grid>
</Border>
```
**CS 改动:**
- 调整响应式布局的字体缩放比例
- 更新颜色绑定使用新的调色板字段
---
### Task 3: 重构 ExtendedWeatherWidget扩展天气组件
**文件:**
- `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml`
- `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs`
**设计目标:**
- 顶部区域:位置+温度+图标横向排列
- 指标区域:使用 Material 3 风格的标签卡片
- 逐小时预报:水平滚动卡片,时间+图标+温度
- 逐日预报:列表式布局,日期+图标+高低温
**XAML 改动:**
```xml
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid>
<components:MaterialWeatherSceneControl x:Name="Scene" />
<Border x:Name="OverlayBorder" />
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto"
Margin="20,16,20,14"
RowSpacing="12">
<!-- 顶部:位置 + 图标 + 温度 -->
<Grid ColumnDefinitions="*,Auto,Auto" VerticalAlignment="Center">
<StackPanel VerticalAlignment="Center">
<TextBlock x:Name="LocationTextBlock"
Text="Weather"
FontSize="13"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
Opacity="0.72" />
<TextBlock x:Name="ConditionTextBlock"
Text="Loading"
FontSize="16"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<components:WeatherIconView x:Name="MainIcon"
Grid.Column="1"
Width="56"
Height="56"
Margin="0,0,10,0" />
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="2"
Text="--°"
FontSize="56"
FontWeight="Bold"
VerticalAlignment="Center" />
</Grid>
<!-- 指标区域 -->
<UniformGrid x:Name="MetricGrid" Grid.Row="1" Rows="1" Columns="3" />
<!-- 逐小时预报 -->
<Border Grid.Row="2"
Background="{DynamicResource SurfaceColor}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="10,8">
<UniformGrid x:Name="HourlyGrid" Rows="1" Columns="6" />
</Border>
<!-- 逐日预报 -->
<Border Grid.Row="3"
Background="{DynamicResource SurfaceColor}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="10,8">
<UniformGrid x:Name="DailyGrid" Rows="1" Columns="5" />
</Border>
</Grid>
</Grid>
</Border>
```
**CS 改动:**
- `CreateMetric` 方法使用圆角卡片Material 3 风格标签
- `BuildHourlyItems` 方法:改进卡片样式,统一圆角
- `BuildDailyItems` 方法:改进卡片样式,统一圆角
---
### Task 4: 重构 HourlyWeatherWidget逐小时天气组件
**文件:**
- `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml`
- `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs`
**设计目标:**
- 顶部简洁信息栏
- 逐小时预报使用 Material 卡片风格
- 时间、图标、温度垂直排列
**XAML 改动:**
```xml
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid>
<components:MaterialWeatherSceneControl x:Name="Scene" />
<Border x:Name="OverlayBorder" />
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*"
Margin="18,14"
RowSpacing="12">
<!-- 顶部信息栏 -->
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
<TextBlock x:Name="TemperatureTextBlock"
Text="--°"
FontSize="42"
FontWeight="Bold"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock"
Text="Loading"
FontSize="15"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="LocationTextBlock"
Text="Weather"
FontSize="12"
FontWeight="Medium"
Opacity="0.72"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock x:Name="RangeTextBlock"
Grid.Column="2"
Text="-- / --"
FontSize="12"
FontWeight="Medium"
VerticalAlignment="Center"
Opacity="0.72"
Margin="0,0,10,0" />
<components:WeatherIconView x:Name="MainIcon"
Grid.Column="3"
Width="48"
Height="48" />
</Grid>
<!-- 逐小时预报卡片容器 -->
<Border Grid.Row="1"
Background="{DynamicResource SurfaceColor}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="8,6">
<UniformGrid x:Name="HourlyGrid" Rows="1" Columns="6" />
</Border>
</Grid>
</Grid>
</Border>
```
---
### Task 5: 重构 MultiDayWeatherWidget多日天气组件
**文件:**
- `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml`
- `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs`
**设计目标:**
- 左侧:当前天气信息(图标+温度+状况+位置)
- 右侧:多日预报列表,使用行式布局
**XAML 改动:**
```xml
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid>
<components:MaterialWeatherSceneControl x:Name="Scene" />
<Border x:Name="OverlayBorder" />
<Grid x:Name="ContentGrid"
ColumnDefinitions="1.2*,1.6*"
Margin="18,14"
ColumnSpacing="14">
<!-- 左侧当前天气 -->
<StackPanel VerticalAlignment="Center" Spacing="6">
<components:WeatherIconView x:Name="MainIcon"
Width="64"
Height="64"
HorizontalAlignment="Left" />
<TextBlock x:Name="TemperatureTextBlock"
Text="--°"
FontSize="42"
FontWeight="Bold" />
<TextBlock x:Name="ConditionTextBlock"
Text="Loading"
FontSize="15"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="LocationTextBlock"
Text="Weather"
FontSize="12"
FontWeight="Medium"
Opacity="0.72"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- 右侧多日预报 -->
<Border Grid.Column="1"
Background="{DynamicResource SurfaceColor}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="10,8">
<ItemsControl x:Name="DailyItemsControl" />
</Border>
</Grid>
</Grid>
</Border>
```
---
### Task 6: 重构 WeatherClockWidget天气时钟组件
**文件:**
- `LanMountainDesktop/Views/Components/WeatherClockWidget.axaml`
- `LanMountainDesktop/Views/Components/WeatherClockWidget.axaml.cs`
**设计目标:**
- 左侧:大字体时间+日期
- 右侧:天气图标+温度+状况
- 信息层级清晰
**XAML 改动:**
```xml
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid>
<components:MaterialWeatherSceneControl x:Name="Scene" />
<Border x:Name="OverlayBorder" />
<Grid x:Name="ContentGrid"
ColumnDefinitions="*,Auto"
Margin="18,12"
ColumnSpacing="12">
<!-- 左侧时间 -->
<StackPanel VerticalAlignment="Center" Spacing="2">
<TextBlock x:Name="TimeTextBlock"
Text="--:--"
FontSize="38"
FontWeight="Bold"
LineHeight="38" />
<TextBlock x:Name="DateTextBlock"
Text="Weather"
FontSize="12"
FontWeight="Medium"
Opacity="0.72"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- 右侧天气 -->
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
HorizontalAlignment="Right"
Spacing="1">
<components:WeatherIconView x:Name="MainIcon"
Width="44"
Height="44"
HorizontalAlignment="Right" />
<TextBlock x:Name="TemperatureTextBlock"
Text="--°"
FontSize="20"
FontWeight="SemiBold"
HorizontalAlignment="Right" />
<TextBlock x:Name="ConditionTextBlock"
Text="Loading"
FontSize="11"
FontWeight="Medium"
HorizontalAlignment="Right"
TextTrimming="CharacterEllipsis"
MaxWidth="100"
Opacity="0.82" />
</StackPanel>
</Grid>
</Grid>
</Border>
```
---
### 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)

View File

@@ -1,342 +0,0 @@
# 天气组件视觉重构 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. **半透明遮罩硬编码**: 所有组件都覆盖 `<Border Background="#30FFFFFF" />` 等硬编码遮罩,不随风格变化。
### 各天气 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` — 移除硬编码遮罩 `<Border Background="#30FFFFFF" />`,改用 palette 驱动的半透明遮罩
- Modify: `WeatherClockWidget.axaml` — 同上
- Modify: `ExtendedWeatherWidget.axaml` — 同上 + 指标区域改用标签样式
- Modify: `HourlyWeatherWidget.axaml` — 同上 + 预报区域改用芯片样式
- Modify: `MultiDayWeatherWidget.axaml` — 同上
- [ ] **Step 1: 移除所有硬编码遮罩**
`<Border Background="#30FFFFFF" />` / `#42FFFFFF` / `#34FFFFFF` / `#38FFFFFF` / `#3CFFFFFF` 替换为 `<Border x:Name="OverlayBorder" />`,在 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. 测试通过

View File

@@ -1,9 +0,0 @@
# Checklist
- [x] `LanMountainDesktop.AirAppRuntime` is included in `LanMountainDesktop.slnx`.
- [x] Launcher no longer hosts `IAirAppLifecycleService`.
- [x] Host fallback starts `LanMountainDesktop.AirAppRuntime`, not `LanMountainDesktop.Launcher air-app-broker`.
- [x] AirApp Runtime is explicitly non-AOT and framework-dependent.
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` passes.
- [x] Related AirApp Runtime tests pass.
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` passes.

View File

@@ -1,21 +0,0 @@
# AirApp Runtime Container
## Goal
Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`.
## Behavior
- Launcher remains the user-facing entry point and pre-starts AirApp Runtime during normal `launch`.
- AirApp Runtime exposes `IAirAppLifecycleService` and `IAirAppRuntimeControlService` on `LanMountainDesktop.AirAppRuntime.v1`.
- Desktop host requests Air APP operations through AirApp Runtime IPC.
- If the runtime pipe is unavailable, the desktop host starts `LanMountainDesktop.AirAppRuntime` directly and retries.
- AirApp Runtime keeps one AirAppHost process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key, with `world-clock` sharing `world-clock:clock-suite:global`.
- AirApp Runtime remains alive while Launcher, Host, requester, or any AirAppHost process is alive.
- AirApp Runtime exits after Launcher/Host/requester are gone and no Air APP windows remain.
## Out of Scope
- Moving Air APP windows into the runtime process.
- Third-party plugin-declared Air APP metadata.
- Persisting the Air APP instance table across OS reboot.

View File

@@ -1,11 +0,0 @@
# Tasks
- [x] Add shared AirApp Runtime IPC/control contracts.
- [x] Add shared AirApp Runtime path resolver and process starter.
- [x] Add `LanMountainDesktop.AirAppRuntime` as a framework-dependent JIT process.
- [x] Move Air APP lifecycle service out of Launcher.
- [x] Make Launcher pre-start AirApp Runtime and attach Host PID after launch.
- [x] Make Host fallback start AirApp Runtime instead of Launcher broker.
- [x] Remove Launcher `air-app-broker` command handling.
- [x] Update packaging scripts and release workflow to include AirApp Runtime.
- [x] Update unit tests and architecture/package assertions.

View File

@@ -1,7 +0,0 @@
# 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.

View File

@@ -1,26 +0,0 @@
# 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.

View File

@@ -1,8 +0,0 @@
# 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.

View File

@@ -1,8 +0,0 @@
# 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.

View File

@@ -1,22 +0,0 @@
# 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.

View File

@@ -1,8 +0,0 @@
# 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.

View File

@@ -1,13 +0,0 @@
# 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.

View File

@@ -1,42 +0,0 @@
# 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.

View File

@@ -1,15 +0,0 @@
# 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.

View File

@@ -1,104 +0,0 @@
# 数据设置页设计文档
## 概述
在设置窗口中新增「数据」设置页,用于可视化展示和管理阑山桌面产生的各类本地数据。采用 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. 清理完成后自动刷新数据
## 安全考虑
- 清理前确认用户意图
- 设置文件不可清理(防止误删配置)
- 清理操作记录日志

View File

@@ -1,777 +0,0 @@
# 数据设置页实现计划
> **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<StorageCategoryInfo> Categories = new List<StorageCategoryInfo>
{
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<StorageCategoryInfo> GetCategories() => Categories;
public async Task<IReadOnlyList<StorageScanResult>> ScanAsync(CancellationToken cancellationToken = default)
{
var results = new List<StorageScanResult>();
var dataRoot = AppDataPathProvider.GetDataRoot();
var logDirectory = AppLogger.LogDirectory;
long totalSize = 0;
var categorySizes = new Dictionary<string, long>();
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<long> 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<long> 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<bool> 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<long> 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<long> 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<DataStorageItemViewModel> 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
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.SettingsPages.DataSettingsPage"
x:DataType="vm:DataSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated"
Spacing="16">
<!-- 存储概览卡片 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="存储概览"
FontSize="16"
FontWeight="SemiBold" />
<!-- 堆叠条形图 -->
<Grid Height="28"
IsVisible="{Binding HasData}">
<Border Background="{DynamicResource ControlFillColorTertiaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True">
<StackPanel Orientation="Horizontal"
x:Name="StorageBarPanel">
<!-- 动态生成分段 -->
</StackPanel>
</Border>
</Grid>
<!-- 总大小和磁盘占比 -->
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="8">
<TextBlock Text="{Binding TotalSizeText}"
FontSize="24"
FontWeight="SemiBold" />
<TextBlock Text="{Binding DiskUsageText}"
VerticalAlignment="Bottom"
Margin="0,0,0,4"
Opacity="0.7" />
</StackPanel>
<Button Grid.Column="1"
Command="{Binding ScanCommand}"
IsEnabled="{Binding !IsScanning}"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular"
FontSize="14" />
<TextBlock Text="刷新" />
</StackPanel>
</Button>
</Grid>
<!-- 图例 -->
<ItemsControl ItemsSource="{Binding Items}"
IsVisible="{Binding HasData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"
ItemWidth="140"
ItemHeight="28" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
<StackPanel Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Center">
<Border Width="12"
Height="12"
CornerRadius="2"
Background="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}" />
<TextBlock Text="{Binding Name}"
FontSize="12"
Opacity="0.8" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- 数据类型详情列表 -->
<TextBlock Text="数据详情"
FontSize="16"
FontWeight="SemiBold"
Margin="0,8,0,0" />
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16"
Margin="0,4">
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
ColumnSpacing="12">
<Border Grid.Column="0"
Width="12"
Height="12"
CornerRadius="2"
Background="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1"
VerticalAlignment="Center">
<TextBlock Text="{Binding Name}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding Description}"
FontSize="12"
Opacity="0.6" />
</StackPanel>
<TextBlock Grid.Column="2"
Text="{Binding SizeText}"
VerticalAlignment="Center"
FontWeight="SemiBold"
Opacity="0.8" />
<Button Grid.Column="3"
Command="{Binding $parent[ItemsControl].((vm:DataSettingsPageViewModel)DataContext).CleanCommand}"
CommandParameter="{Binding Id}"
IsVisible="{Binding IsCleanable}"
IsEnabled="{Binding !IsCleaning}"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal"
Spacing="4">
<fi:FluentIcon Icon="Delete"
IconVariant="Regular"
FontSize="14" />
<TextBlock Text="清理" />
</StackPanel>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 一键清理 -->
<Button Command="{Binding CleanAllCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Margin="0,8">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="Broom"
IconVariant="Regular"
FontSize="16" />
<TextBlock Text="一键清理所有可清理数据" />
</StackPanel>
</Button>
</StackPanel>
</ScrollViewer>
</UserControl>
```
---
## 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. 点击「数据」选项卡,确认:
- 堆叠条形图显示各类数据占比
- 总大小和磁盘占比显示正确
- 数据详情列表显示每类数据大小
- 刷新按钮可以重新扫描
- 清理按钮可以清理对应数据

View File

@@ -1,13 +0,0 @@
# 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.

View File

@@ -1,29 +0,0 @@
# 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.

View File

@@ -1,14 +0,0 @@
- [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 全部通过

View File

@@ -1,73 +0,0 @@
# 融合桌面组件库分类图标统一规格
## 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<Icon>(iconKey, ignoreCase: true, out var icon)` 解析
- 解析失败时回退到 `Icon.Apps`
- 移除所有三处硬编码映射方法
### Requirement: ComponentLibraryCategoryViewModel.Icon 类型
原类型为 `Symbol`,修改为 `Icon`,与 `fi:FluentIcon` 控件的 `Icon` 依赖属性类型一致。
## REMOVED Requirements
无移除的需求。

View File

@@ -1,38 +0,0 @@
# Tasks
- [x] Task 1: 创建共享分类图标映射工具
- [x] SubTask 1.1: 在 `LanMountainDesktop.ComponentSystem` 命名空间下创建 `ComponentCategoryIconResolver` 静态类
- [x] SubTask 1.2: 实现 `ResolveCategoryIcon(string categoryId, IEnumerable<DesktopComponentDefinition> 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 依赖于所有前置任务

View File

@@ -1,12 +0,0 @@
# Checklist
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
- [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.

View File

@@ -1,24 +0,0 @@
# Launcher Managed Air APP Lifecycle
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
## 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 <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.

View File

@@ -1,13 +0,0 @@
# Tasks
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
- [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 <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.

View File

@@ -3,7 +3,6 @@
- [ ] New install shows OOBE once.
- [ ] Same-user reinstall does not show OOBE again.
- [ ] `postinstall` launch path is handled without misclassifying the user state.
- [ ] `plugin-install` does not auto-enter OOBE.
- [ ] `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).

View File

@@ -23,11 +23,12 @@ Stabilize the launcher startup path so that:
- `launchSource` values are treated as:
- `normal`
- `postinstall`
- `apply-update`
- `plugin-install`
- `debug-preview`
- Automatic OOBE is allowed only for normal user-mode startup.
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
- Allowed elevation paths are limited to:
- the installer itself
- full installer update application

View File

@@ -65,19 +65,3 @@
- 托盘失败时应用仍保持可恢复。
- Launcher 与应用设置页显示相同版本。
- 100% / 150% / 200% / 250% 缩放下Launcher OOBE、主窗口入场、通知位置与动画正常。
### 5. Launcher IPC and error surface follow-up
- The legacy `LanMountainDesktop_Launcher` named-pipe startup progress channel is retired. Public IPC notifications and host exit codes are the only startup state sources.
- Normal Launcher launches must probe public IPC for an existing Host before starting a new Host process. Host no longer owns multi-instance policy, activation prompts, or the old single-instance pipe.
- `SecondaryActivationSucceeded` is a success terminal state. `SecondaryActivationFailed` and `RestartLockNotAcquired` may surface as failures only after public IPC recovery has failed.
- Launcher startup errors must use FluentAvalonia resources, Fluent icons, an InfoBar recovery hint, and copyable diagnostics instead of the old hard-coded dark panel.
### 6. Multi-instance behavior setting
- App settings include `MultiInstanceLaunchBehavior` with default `NotifyAndOpenDesktop`.
- General settings exposes the behavior under Basic Settings with four choices: restart app, open desktop silently, prompt only, and notify plus open desktop.
- Launcher reads the Host `settings.json` before a normal launch and applies the selected behavior when public IPC reports an existing Host.
- `PromptOnly` shows a Fluent Launcher prompt and does not open the desktop automatically.
- `NotifyAndOpenDesktop` activates the existing Host and shows the already-running notice from Launcher.
- `RestartApp` requests restart through public IPC and must not create a second Host if the restart request fails.

View File

@@ -12,10 +12,3 @@
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
- [x] 补充规格与版本同步说明文档。
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
- [x] Remove the legacy `LanMountainDesktop_Launcher` startup progress pipe; launcher progress now uses public IPC plus host exit-code classification only.
- [x] Move normal multi-open probing into Launcher before host launch and remove Host-side single-instance prompt/listener code.
- [x] Refresh the Launcher error window with Fluent resources, InfoBar, Fluent icons, command bar actions, and copyable diagnostic details.
- [x] Add app-level `MultiInstanceLaunchBehavior` setting and expose it in General > Basic Settings.
- [x] Make Launcher apply restart/open silently/prompt only/notify and open behavior before starting a new Host.
- [x] Add a Fluent Launcher multi-instance prompt; Host public IPC stays limited to activation/status/restart/exit actions.

View File

@@ -4,14 +4,14 @@
- Tray menu `Exit App` must commit an irreversible host shutdown request.
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, and telemetry resources before the forced-exit deadline.
- 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.
- 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 perform multi-instance detection through public IPC.
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
- Repeated tray clicks during shutdown are ignored and logged.
- Repeated component-library clicks focus the existing window instead of opening duplicates.

View File

@@ -1,42 +0,0 @@
# 本地化修复 Checklist
## MainWindow 修复
- [ ] `TaskbarProfileDisplayNameTextBlock.Text` 在中文下显示"用户"(或保持动态)
- [ ] `TaskbarProfileSettingsActionTextBlock.Text` 在中文下显示"设置"
- [ ] `TaskbarProfileDesktopEditActionTextBlock.Text` 在中文下显示"桌面编辑"
- [ ] `TaskbarProfilePowerActionTextBlock.Text` 在中文下显示"电源"
- [ ] `TaskbarPowerBackTextBlock.Text` 在中文下显示"返回"
- [ ] `TaskbarPowerTitleTextBlock.Text` 在中文下显示"电源"
- [ ] `PowerShutdownTextBlock.Text` 在中文下显示"关机"
- [ ] `PowerRestartTextBlock.Text` 在中文下显示"重启"
- [ ] `PowerLogoutTextBlock.Text` 在中文下显示"注销"
- [ ] `PowerSleepTextBlock.Text` 在中文下显示"睡眠"
- [ ] `PowerLockTextBlock.Text` 在中文下显示"锁定屏幕"
- [ ] `ComponentLibraryTitleTextBlock.Text` 在中文下显示"桌面编辑"
- [ ] `ComponentLibraryEmptyTextBlock.Text` 在中文下显示"左右滑动选择类别,点击进入,然后拖动组件到桌面放置。"
- [ ] `ComponentLibraryBackTextBlock.Text` 在中文下显示"返回"
- [ ] `ComponentLibraryCollapsedChipTextBlock.Text` 在中文下显示"桌面编辑"
## Launcher 修复
- [ ] `SplashWindow` 在中文下显示中文启动文本
- [ ] `DataLocationPromptWindow` 在中文下全部显示中文
- [ ] `ErrorWindow` 在中文下全部显示中文
- [ ] `LoadingDetailsWindow` 在中文下全部显示中文
- [ ] `UpdateWindow` 在中文下显示中文标题
## 组件修复
- [ ] `BrowserWidget` 在中文下显示"浏览器运行时不可用"
- [ ] `WhiteboardWidget` 工具提示在中文下显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
- [ ] `HolidayCalendarWidget` 在中文下显示"节假日倒计时"、"天"
- [ ] `BilibiliHotSearchWidget` 在中文下显示"热门话题"
- [ ] `WallpaperSettingsPage` 自定义颜色 Tooltip 在中文下显示"自定义颜色"
## 资源文件
- [ ] `zh-CN.json` 包含所有新增键值
- [ ] `en-US.json` 包含所有新增键值
- [ ] Launcher 本地化文件包含所有新增键值
## 构建与质量
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过,无错误
- [ ] 无新增警告
- [ ] 无遗漏的硬编码英文(通过 `grep -r 'Text="[a-zA-Z]'` 等检查)

View File

@@ -1,85 +0,0 @@
# 本地化修复 Spec
## Why
- 项目在中文设置下,多处 UI 仍显示英文。
- 主要问题集中在:
1. `MainWindow.axaml` 中任务栏头像弹窗、电源菜单、组件库等文本硬编码为英文,且未被 `ApplyLocalization()` 覆盖。
2. `LanMountainDesktop.Launcher` 的所有视图完全没有接入本地化系统。
3. 部分组件BrowserWidget、WhiteboardWidget、HolidayCalendarWidget 等)存在未覆盖的硬编码英文。
4. 少量设置页面 Tooltip 硬编码英文。
## What Changes
### 1. MainWindow.axaml 硬编码修复
将以下硬编码文本改为由 `ApplyLocalization()` 通过 `L()` 动态设置:
- 任务栏头像弹窗:`User``power.user` / `Settings``settings.title` / `Edit Desktop``button.component_library` / `Power``power.title`
- 电源菜单:`Back``common.back` / `Power``power.title` / `Shutdown``power.shutdown` / `Restart``power.restart` / `Log Out``power.logout` / `Sleep``power.sleep` / `Lock Screen``power.lock_screen`
- 组件库:`Widgets``component_library.title` / `Back``common.back` / `No components.``component_library.empty`
- 悬浮芯片:`Widgets``component_library.title`
### 2. Launcher 视图本地化
`LanMountainDesktop.Launcher/Views/` 下的窗口引入独立本地化机制(复用 `LocalizationService` 或内嵌资源字典):
- `SplashWindow.axaml``LanMountain Desktop``Initializing...`
- `DataLocationPromptWindow.axaml`:全部文本
- `ErrorWindow.axaml`:全部文本
- `LoadingDetailsWindow.axaml`:全部文本
- `UpdateWindow.axaml``Update`
### 3. 组件硬编码修复
- `BrowserWidget.axaml``Browser runtime unavailable.` → 新增键 `browser.widget.unavailable`
- `WhiteboardWidget.axaml``Pen` / `Eraser` / `Clear` / `Export SVG` → 新增键 `whiteboard.tool.pen`
- `HolidayCalendarWidget.axaml``Holiday countdown` / `Days` → 新增键 `holiday.widget.title` / `holiday.widget.days`
- `BilibiliHotSearchWidget.axaml``Trending Topic` → 新增键 `bilihot.widget.trending_topic`
- `WallpaperSettingsPage.axaml``Custom color` Tooltip → 复用 `settings.wallpaper.custom_color_tooltip`
### 4. 本地化资源文件补充
`zh-CN.json``en-US.json` 中补充上述新增键值。
## Impact
- Affected code:
- `LanMountainDesktop/Views/MainWindow.axaml`
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
- `LanMountainDesktop.Launcher/Views/*.axaml`(多个文件)
- `LanMountainDesktop/Views/Components/BrowserWidget.axaml`
- `LanMountainDesktop/Views/Components/WhiteboardWidget.axaml`
- `LanMountainDesktop/Views/Components/HolidayCalendarWidget.axaml`
- `LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml`
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
- `LanMountainDesktop/Localization/zh-CN.json`
- `LanMountainDesktop/Localization/en-US.json`
- Affected behavior:
- 中文设置下上述位置将正确显示中文。
- Launcher 各窗口将支持中英文切换。
---
## Requirements
### Requirement: MainWindow 任务栏弹窗与电源菜单本地化
系统 SHALL 在 `ApplyLocalization()` 中覆盖任务栏头像弹窗和电源菜单的所有文本。
#### Scenario: 中文设置下打开任务栏弹窗
- **WHEN** 语言设置为中文
- **THEN** 弹窗中显示"设置"、"桌面编辑"、"电源"等中文文本
- **AND THEN** 电源菜单中显示"返回"、"关机"、"重启"、"注销"、"睡眠"、"锁定屏幕"等中文文本
### Requirement: Launcher 窗口本地化
系统 SHALL 让 Launcher 的所有窗口文本通过本地化服务获取。
#### Scenario: 中文设置下启动应用
- **WHEN** 语言设置为中文
- **THEN** SplashWindow 显示中文启动文本
- **AND THEN** 数据位置选择、错误页、加载详情页等显示中文
### Requirement: 组件与设置页硬编码修复
系统 SHALL 移除或覆盖所有组件和设置页中的英文硬编码文本。
#### Scenario: 中文设置下查看各组件
- **WHEN** 语言设置为中文
- **THEN** BrowserWidget 显示"浏览器运行时不可用"
- **AND THEN** WhiteboardWidget 工具提示显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
- **AND THEN** HolidayCalendarWidget 显示"节假日倒计时"、"天"
- **AND THEN** BilibiliHotSearchWidget 显示"热门话题"
- **AND THEN** 壁纸设置页自定义颜色 Tooltip 显示"自定义颜色"

View File

@@ -1,39 +0,0 @@
# 本地化修复 Tasks
## Task 1: MainWindow.axaml 硬编码文本移除与代码覆盖
- [ ] 1.1 在 `MainWindow.axaml` 中,将任务栏头像弹窗的 `User``Settings``Edit Desktop``Power``Text` 属性改为空或绑定(保留 x:Name
- [ ] 1.2 在 `MainWindow.axaml` 中,将电源菜单的 `Back``Power``Shutdown``Restart``Log Out``Sleep``Lock Screen``Text` 属性改为空或绑定
- [ ] 1.3 在 `MainWindow.axaml` 中,将组件库的 `Widgets``Back``No components.``Text` 属性改为空或绑定
- [ ] 1.4 在 `MainWindow.axaml` 中,将悬浮芯片的 `Widgets``Text` 属性改为空或绑定
- [ ] 1.5 在 `MainWindow.SettingsHardCut.Stubs.cs``ApplyLocalization()` 中补充上述所有控件的 `L()` 赋值
## Task 2: Launcher 视图本地化
- [ ] 2.1 在 `LanMountainDesktop.Launcher` 中引入 `LocalizationService`(或共享主应用服务)
- [ ] 2.2 为 Launcher 创建独立的 `Localization/` 目录和 `zh-CN.json` / `en-US.json`
- [ ] 2.3 修改 `SplashWindow.axaml`:将 `LanMountain Desktop``Initializing...` 改为动态绑定
- [ ] 2.4 修改 `DataLocationPromptWindow.axaml`:将所有文本改为动态绑定
- [ ] 2.5 修改 `ErrorWindow.axaml`:将所有文本改为动态绑定
- [ ] 2.6 修改 `LoadingDetailsWindow.axaml`:将所有文本改为动态绑定
- [ ] 2.7 修改 `UpdateWindow.axaml`:将 `Update` 改为动态绑定
- [ ] 2.8 在 Launcher 启动流程中初始化语言设置
## Task 3: 组件硬编码修复
- [ ] 3.1 `BrowserWidget.axaml`:将 `Browser runtime unavailable.` 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.2 `WhiteboardWidget.axaml`:将 `Pen``Eraser``Clear``Export SVG` Tooltip 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.3 `HolidayCalendarWidget.axaml`:将 `Holiday countdown``Days` 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.4 `BilibiliHotSearchWidget.axaml`:将 `Trending Topic` 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.5 `WallpaperSettingsPage.axaml`:将 `Custom color` Tooltip 改为绑定到 `settings.wallpaper.custom_color_tooltip`
## Task 4: 本地化资源文件补充
- [ ] 4.1 在 `zh-CN.json` 中补充以下键值:
- `browser.widget.unavailable`
- `whiteboard.tool.pen``whiteboard.tool.eraser``whiteboard.tool.clear``whiteboard.tool.export_svg`
- `holiday.widget.title``holiday.widget.days`
- `bilihot.widget.trending_topic`
- `power.user`(或复用现有键)
- [ ] 4.2 在 `en-US.json` 中补充上述键值的英文版本
- [ ] 4.3 为 Launcher 创建独立的本地化 JSON 文件并填充中英文
## Task 5: 验证
- [ ] 5.1 执行 `dotnet build LanMountainDesktop.slnx -c Debug` 确保编译通过
- [ ] 5.2 检查是否有遗漏的硬编码英文(通过正则搜索)

View File

@@ -1,42 +0,0 @@
# 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.

View File

@@ -1,20 +0,0 @@
# 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.

View File

@@ -1,10 +0,0 @@
# 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.

View File

@@ -1,12 +0,0 @@
# 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.

View File

@@ -1,62 +0,0 @@
# 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.

View File

@@ -1,13 +0,0 @@
# 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.

View File

@@ -0,0 +1,13 @@
# 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.
- [ ] 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.

View File

@@ -0,0 +1,44 @@
# PDC Incremental Update Migration
## Goal
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
## Stage 1 (Completed)
- Release workflow removed VeloPack-based release packaging.
- Signed FileMap path was restored as an interim release mechanism.
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
## 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.
- 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.
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
Expected S3 layout:
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
- `lanmountain/update/installers/<platform>/<arch>/*`
## Acceptance
- `release.yml` includes PDCC publish steps and no Velopack steps.
- 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.
- Launcher can apply both:
- legacy signed `files.json + update.zip`
- PDC FileMap object-repo payload.
- Rollback semantics remain unchanged.
## Deprecated Notes
- The following interim outputs are compatibility-only (not the long-term primary path):
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`

View File

@@ -0,0 +1,15 @@
# Tasks
- [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/<platform>/<arch>/`.
- [ ] 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.

View File

@@ -1,512 +0,0 @@
# PLONDS Comparator 改造设计
> 日期2026-05-30
> 状态:待审批
## 1. 背景与动机
PLONDSPenguin Logistics Online Network Distribution System是 LanMountainDesktop 的文件驱动式分布式更新系统。当前 Comparator 工作流存在以下问题:
1. **产出物过于复杂**:生成 `update-{platform}.zip``plonds-filemap-{platform}.json``plonds-filemap-{platform}.json.sig``platform-summary-{platform}.json``plonds-static.zip` 等多个文件,客户端消费困难
2. **模型定义重复**`Plonds.Shared``Plonds.Core`、宿主侧、Launcher 侧各自定义独立的 DTO字段名不一致
3. **签名机制过重**RSA 签名增加了 CI 复杂度(需要管理密钥),且对文件驱动式更新系统而言 SHA256 哈希校验已足够
4. **平台覆盖不当**Linux 平台不需要 PLONDS 支持macOS 尚未接入,但代码中硬编码了三个平台
5. **工作流间 artifact 传递脆弱**Comparator → Publisher 通过 artifact 传递 `plonds-static.zip`,容易断裂
## 2. 设计目标
- 产出物精简为两个文件:`changed.zip` + `PLONDS.json`
- 去掉 RSA 签名,只用 SHA256/MD5 校验
- 只关注 Windows 平台
- 统一模型定义,消除 DTO 重复
- 保持 Comparator 和 Publisher 两个工作流的职责分离
## 3. 新产出物定义
### 3.1 changed.zip
只包含与上一版本有差异的文件action 为 `add``replace` 的文件),目录结构与部署目录一致。
### 3.2 PLONDS.json
```json
{
"formatVersion": "2.0",
"currentVersion": "1.2.0",
"previousVersion": "1.1.0",
"isFullUpdate": false,
"requiresCleanInstall": false,
"channel": "stable",
"platform": "windows-x64",
"updatedAt": "2026-05-30T12:00:00Z",
"filesMap": {
"LanMountainDesktop.exe": {
"action": "replace",
"sha256": "abc123...",
"size": 1024000
},
"LanMountainDesktop.dll": {
"action": "reuse",
"sha256": "def456...",
"size": 512000
},
"OldModule.dll": {
"action": "delete",
"sha256": "",
"size": 0
}
},
"changedFilesMap": {
"LanMountainDesktop.exe": {
"archivePath": "LanMountainDesktop.exe",
"sha256": "abc123...",
"size": 1024000
}
},
"checksums": {
"changed.zip": "md5:9a8b7c6d..."
}
}
```
### 3.3 字段语义
| 字段 | 类型 | 说明 |
|------|------|------|
| `formatVersion` | string | 协议版本,固定 `"2.0"` |
| `currentVersion` | string | 当前发布版本 |
| `previousVersion` | string | 基线版本(全量更新时为 `"0.0.0"` |
| `isFullUpdate` | bool | 是否为全量更新(找不到基线版本时为 true |
| `requiresCleanInstall` | bool | 启动器是否也更新了——如果是,客户端不走增量流程,让用户重新运行安装器 |
| `channel` | string | 更新通道:`stable``preview` |
| `platform` | string | 平台标识:`windows-x64` |
| `updatedAt` | string | ISO 8601 时间戳 |
| `filesMap` | object | 全量文件图:每个文件的 action + sha256 + size |
| `changedFilesMap` | object | 变更文件图:只包含需要从 changed.zip 解压的文件 |
| `checksums` | object | 产出物的 MD5 值 |
### 3.4 filesMap 中 action 的值
| Action | 含义 | changed.zip 中是否包含 |
|--------|------|----------------------|
| `add` | 新增文件 | ✅ |
| `replace` | 替换文件 | ✅ |
| `reuse` | 复用上一版本文件 | ❌ |
| `delete` | 删除文件 | ❌ |
### 3.5 requiresCleanInstall 判断逻辑
比较 `LanMountainDesktop.Launcher.exe` 在当前版本和基线版本中的 SHA256
- 如果 SHA256 不同 → `requiresCleanInstall = true`
- 如果 SHA256 相同或没有基线版本 → `requiresCleanInstall = false`
## 4. Plonds.Tool build-delta 命令改造
### 4.1 新命令签名
```
build-delta --platform <platform>
--current-version <version>
--current-zip <file>
--output-dir <dir>
--channel <channel>
[--baseline-version <version>]
[--baseline-zip <file>]
[--launcher-path <relative-path>]
```
### 4.2 参数说明
| 参数 | 必需 | 说明 |
|------|------|------|
| `--platform` | 是 | 平台标识,如 `windows-x64` |
| `--current-version` | 是 | 当前发布版本号 |
| `--current-zip` | 是 | 当前版本的 payload zip 路径 |
| `--output-dir` | 是 | 输出目录 |
| `--channel` | 是 | 更新通道 |
| `--baseline-version` | 否 | 基线版本号(省略则视为全量更新) |
| `--baseline-zip` | 否 | 基线版本的 payload zip 路径(省略则视为全量更新) |
| `--launcher-path` | 否 | Launcher 可执行文件的相对路径,默认 `LanMountainDesktop.Launcher.exe` |
### 4.3 删除的参数
| 参数 | 原因 |
|------|------|
| `--current-tag` | 不再需要version 就够了 |
| `--private-key` | 去掉签名 |
| `--is-full-payload` | 自动判断:没有 baseline-zip 就是全量 |
| `--static-output-dir` | 不再生成 S3 静态布局 |
| `--update-base-url` | 不再生成 S3 URL |
| `--baseline-tag` | 不再需要 |
### 4.4 内部逻辑
```
1. 解压 current-zip → currentDir
2. 如果有 baseline-zip → 解压 → baselineDir
否则 → baselineDir = 空(全量更新)
3. 扫描 currentDir → 计算 SHA256
4. 扫描 baselineDir → 计算 SHA256如果有
5. 对比生成 filesMap:
- 两个版本都有且 SHA256 相同 → reuse
- 两个版本都有但 SHA256 不同 → replace
- 只在新版本中存在 → add
- 只在旧版本中存在 → delete
6. 从 filesMap 提取 changedFilesMap:
- 只包含 action=add/replace 的条目
- 添加 archivePath在 changed.zip 中的路径)
7. 打包 changed.zip:
- 只包含 add/replace 的文件
- 保持原始目录结构
8. 判断 requiresCleanInstall:
- 比较 Launcher 可执行文件在两个版本中的 SHA256
- 如果不同 → requiresCleanInstall=true
9. 计算 changed.zip 的 MD5
10. 生成 PLONDS.json
11. 输出到 output-dir:
- changed.zip
- PLONDS.json
```
### 4.5 不再生成的产物
| 旧产物 | 处置 |
|--------|------|
| `update-{platform}.zip` | 被 `changed.zip` 替代 |
| `plonds-filemap-{platform}.json` | 被 `PLONDS.json` 替代 |
| `plonds-filemap-{platform}.json.sig` | 去掉签名 |
| `platform-summary-{platform}.json` | 不再需要 |
| `plonds-static.zip` | 不再生成 S3 静态布局 |
| `meta/channels/...` | 不再由 Tool 生成,由 Publisher 负责 |
## 5. Plonds.Shared 模型改造
### 5.1 删除的模型
| 模型 | 原因 |
|------|------|
| `PlondsFileMap` | 被新的 `PlondsManifest` 替代 |
| `PlondsFileEntry` | 被新的 `PlondsFileEntry` 替代 |
| `PlondsComponent` | 不再有组件概念 |
| `PlondsDistributionInfo` | 不再生成分发文档 |
| `PlondsChannelPointer` | 由 Publisher 用脚本生成 |
| `PlondsReleaseManifest` | 不再需要 |
| `PlondsReleasePlatformEntry` | 不再需要 |
| `PlondsSignatureDescriptor` | 去掉签名 |
| `PlondsMirrorAsset` | 由 Publisher 处理 |
| `PlondsMirrorEntry` | 由 Publisher 处理 |
| `PlondsMetadataCatalog` | 不再需要 |
| `PlondsAssetEntry` | 不再需要 |
### 5.2 新模型定义
```csharp
// PlondsManifest — 对应 PLONDS.json
public sealed record PlondsManifest(
string FormatVersion,
string CurrentVersion,
string PreviousVersion,
bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums);
// PlondsFileEntry — filesMap 中的条目
public sealed record PlondsFileEntry(
string Action, // add | replace | reuse | delete
string Sha256,
long Size);
// PlondsChangedFileEntry — changedFilesMap 中的条目
public sealed record PlondsChangedFileEntry(
string ArchivePath, // 在 changed.zip 中的路径
string Sha256,
long Size);
```
### 5.3 设计决策
- `FilesMap``ChangedFilesMap``IReadOnlyDictionary<string, T>` 而非 `IReadOnlyList<T>`key 就是文件相对路径,查找 O(1)
- 去掉 `Component` 概念——当前只有一个 `app` 组件,分层没有实际意义
- `FormatVersion` 固定为 `"2.0"`,与旧格式区分
## 6. Comparator 工作流改造
### 6.1 保留两个工作流
- **Comparator**`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
- **Publisher**`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
### 6.2 Comparator 改造后步骤
```yaml
# plonds-comparator.yml
触发: release.published / release.prereleased / workflow_dispatch
jobs:
compare:
runs-on: ubuntu-latest
steps:
- Checkout
- 解析发布上下文
→ RELEASE_TAG, RELEASE_VERSION, RELEASE_CHANNEL
- Setup .NET
- 构建 PLONDS Tool
- 解析基线版本
→ 查找上一个同频道 Release
→ 如果有 → 记录 baseline_tag, baseline_version
→ 如果没有 → is_full_update=true
- 下载 payload zips
→ 下载当前版本 files-windows-x64.zip
→ 下载基线版本 files-windows-x64.zip (如果有)
- 运行 build-delta
→ dotnet run Plonds.Tool -- build-delta \
--platform windows-x64 \
--current-version $VERSION \
--current-zip files-windows-x64.zip \
--output-dir plonds-output \
--channel $CHANNEL \
[--baseline-version $BASELINE_VERSION] \
[--baseline-zip baseline-files-windows-x64.zip]
- 上传到 GitHub Release
→ gh release upload changed.zip PLONDS.json
- 传递元数据给 Publisher
→ 上传 artifact: plonds-run-metadata (tag.txt)
```
### 6.3 与当前步骤的差异
| 当前步骤 | 改造后 |
|---------|--------|
| 准备签名密钥 | ❌ 删除 |
| 解析基线计划 (pwsh三平台) | ✅ 简化:只找 Windows逻辑简化 |
| 下载 payload zips (pwsh三平台) | ✅ 简化:只下载 Windows |
| 构建增量资产 (pwsh含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
## 7. 双模式差分生成
### 7.1 概述
Comparator 支持两种差分生成方法,通过 `workflow_dispatch``compare_method` 输入项选择:
| 方法 | 标识 | 核心思路 |
|------|------|---------|
| 方法一 | `file-compare` | 下载两个版本的 files zip全量文件哈希对比 |
| 方法二 | `commit-analyze` | 分析两个版本之间的 git commit映射源码变更到产物文件 |
### 7.2 GitHub Actions 触发器新增输入项
```yaml
workflow_dispatch:
inputs:
tag: ...
baseline_tag: ...
channel: ...
compare_method: # 新增
description: '比较方法'
type: choice
default: file-compare
options:
- file-compare
- commit-analyze
hash_algorithm: # 新增(仅方法一)
description: '哈希算法'
type: choice
default: sha256
options:
- sha256
- md5
```
当由 `release` 事件触发时,默认使用 `file-compare` + `sha256`
### 7.3 方法一文件对比模式file-compare
**流程:**
```
1. 下载当前版本 files-windows-x64.zip
2. 下载基线版本 files-windows-x64.zip如果有
3. 解压两个 zip 到临时目录
4. 用指定哈希算法sha256/md5扫描两个目录的所有文件
5. 对比哈希值,生成 filesMapadd/replace/reuse/delete
6. 从当前版本目录中提取 add/replace 的文件 → changed.zip
7. 生成 PLONDS.json
```
**PlondsDeltaBuildOptions 新增参数:**
```csharp
string HashAlgorithm = "sha256" // "sha256" | "md5"
```
**哈希算法对 PLONDS.json 的影响:**
- `sha256``filesMap``changedFilesMap` 中使用 `sha256` 字段
- `md5``filesMap``changedFilesMap` 中使用 `md5` 字段
### 7.4 方法二Commit 分析模式commit-analyze
**流程:**
```
1. 下载当前版本 files-windows-x64.zip
2. 解压到临时目录
3. git log --name-only baseline_tag..current_tag
→ 得到两个版本之间的 commit 列表和涉及的源码文件
4. 过滤:只保留源码目录下的文件
5. 用简单规则映射源码文件到产物文件
6. 从当前版本的解压目录中提取映射到的产物文件 → changed.zip
7. 生成 PLONDS.json
8. 如果没有源码变更 → 自动回退到方法一
```
**源码目录过滤规则:**
只分析以下目录下的文件变更:
| 目录 | 说明 |
|------|------|
| `LanMountainDesktop/` | 主宿主应用 |
| `LanMountainDesktop.Launcher/` | 启动器 |
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 |
| `LanMountainDesktop.PluginSdk/` | 插件 SDK |
| `LanMountainDesktop.Appearance/` | 外观系统 |
| `LanMountainDesktop.Settings.Core/` | 设置核心 |
| `LanMountainDesktop.ComponentSystem/` | 组件系统 |
忽略的目录:`docs/``scripts/``.github/``.trae/``PenguinLogisticsOnlineNetworkDistributionSystem/`
**源码到产物的映射规则:**
| 源码路径模式 | 映射到产物文件 |
|-------------|--------------|
| `LanMountainDesktop/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.dll`, `LanMountainDesktop.exe` |
| `LanMountainDesktop.Launcher/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.Launcher.exe` |
| `LanMountainDesktop.Shared.Contracts/**/*.cs` | `LanMountainDesktop.Shared.Contracts.dll` |
| `LanMountainDesktop.PluginSdk/**/*.cs` | `LanMountainDesktop.PluginSdk.dll` |
| `LanMountainDesktop.Appearance/**/*.cs` | `LanMountainDesktop.Appearance.dll` |
| `LanMountainDesktop.Settings.Core/**/*.cs` | `LanMountainDesktop.Settings.Core.dll` |
| `LanMountainDesktop.ComponentSystem/**/*.cs` | `LanMountainDesktop.ComponentSystem.dll` |
| `**/*.json`(配置文件) | 同路径的 .json |
| 其他无法映射的变更 | 保守标记 → 所有核心 .dll/.exe |
**方法二在 Plonds.Tool 中的新命令:**
```
build-delta-from-commits --platform <platform>
--current-version <version>
--current-zip <file>
--output-dir <dir>
--channel <channel>
--baseline-tag <tag>
--current-tag <tag>
[--source-dirs <dir1,dir2,...>]
[--fallback-zip <file>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--platform` | 是 | 平台标识 |
| `--current-version` | 是 | 当前发布版本号 |
| `--current-zip` | 是 | 当前版本的 payload zip |
| `--output-dir` | 是 | 输出目录 |
| `--channel` | 是 | 更新通道 |
| `--baseline-tag` | 是 | 基线版本的 git tag |
| `--current-tag` | 是 | 当前版本的 git tag |
| `--source-dirs` | 否 | 要分析的源码目录列表(逗号分隔) |
| `--fallback-zip` | 否 | 回退到方法一时使用的基线 zip |
**回退逻辑:**
如果 `git log` 分析后发现没有源码目录下的文件变更(比如只有 docs/ 变更),则自动回退到方法一:
1. 如果提供了 `--fallback-zip` → 用方法一对比两个 zip
2. 如果没有提供 → 生成全量更新(`isFullUpdate=true`
### 7.5 方法二的 PLONDS.json 特殊处理
方法二无法像方法一那样生成完整的 `filesMap`(因为不知道哪些文件是 reuse 的),因此:
- `filesMap` 只包含映射到的变更文件(标记为 `add``replace`
- 不包含 `reuse``delete` 条目
- `isFullUpdate` 始终为 `false`(除非回退到方法一且无基线)
- `requiresCleanInstall` 根据 Launcher.exe 是否在映射到的变更文件列表中判断
### 7.6 工作流中的条件分支
```yaml
- name: Run build-delta
shell: bash
run: |
if [[ "$COMPARE_METHOD" == "commit-analyze" ]]; then
# 方法二
dotnet run --project ... -- build-delta-from-commits \
--platform windows-x64 \
--current-version $RELEASE_VERSION \
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
--output-dir $PWD/plonds-output \
--channel $RELEASE_CHANNEL \
--baseline-tag $BASELINE_TAG \
--current-tag $RELEASE_TAG \
--fallback-zip $PWD/plonds-input/baseline-files-windows-x64.zip
else
# 方法一
dotnet run --project ... -- build-delta \
--platform windows-x64 \
--current-version $RELEASE_VERSION \
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
--output-dir $PWD/plonds-output \
--channel $RELEASE_CHANNEL \
--hash-algorithm $HASH_ALGORITHM \
--baseline-version $BASELINE_VERSION \
--baseline-zip $PWD/plonds-input/baseline-files-windows-x64.zip
fi
```
方法二时,基线 zip 仍然需要下载(用于回退),但不需要解压(除非回退)。
### 7.7 两种方法的步骤差异
| 步骤 | 方法一 (file-compare) | 方法二 (commit-analyze) |
|------|----------------------|------------------------|
| 下载基线 zip | ✅ 需要 | ✅ 需要(用于回退) |
| 下载当前 zip | ✅ | ✅ |
| 解压两个 zip | ✅ | ✅ 只解压当前(回退时解压基线) |
| git diff/log | ❌ | ✅ 需要 fetch-depth:0 |
| 哈希对比 | ✅ 两个目录全量扫描 | ❌ 不做(除非回退) |
| 源码→产物映射 | ❌ | ✅ |
| 回退逻辑 | ❌ | ✅ 无源码变更时回退方法一 |
## 8. 不在本次改造范围内的事项
- Publisher 工作流改造(后续单独设计)
- Rollback 工作流改造(后续单独设计)
- 宿主侧客户端代码改造PlondsUpdateApplier 等,后续单独设计)
- Launcher 侧客户端代码改造(后续单独设计)
- Plonds.Api 项目处置(后续决定是否保留)
- `build-index``build-plonds``generate``publish``sign``pack-payload` 等 Tool 命令的清理(后续处理)

View File

@@ -1,5 +0,0 @@
# Runtime Packaging Fix Checklist
- [x] `dotnet build LanMountainDesktop.slnx -c Debug -v minimal` succeeds.
- [x] Runtime probe, AirAppHost startup, and packaging policy tests pass.
- [ ] Full `win-x64` package dry run completes without timeout.

View File

@@ -1,12 +0,0 @@
# Runtime Packaging Fix
Windows releases use the launcher as the only self-contained bootstrapper. The
desktop host and AirAppHost are framework-dependent and rely on an
architecture-matched .NET 10 Desktop Runtime installed by the Inno setup flow.
Acceptance:
- Windows installer payload does not bundle .NET shared runtime files.
- Inno Setup downloads and silently installs the matching .NET 10 Desktop Runtime.
- Launcher blocks framework-dependent host startup with `dotnet_runtime_missing` when the runtime is unavailable.
- AirAppHost startup uses packaged executables or an explicit architecture-matched dotnet host for DLL fallback.

View File

@@ -1,7 +0,0 @@
# Runtime Packaging Fix Tasks
- [x] Add launcher-side .NET runtime probe and host startup guard.
- [x] Update AirAppHost process start behavior for packaged exe and DLL fallback.
- [x] Update Windows packaging scripts and CI release workflow.
- [x] Update Inno Setup prerequisite download/install flow.
- [x] Add regression tests and runtime packaging documentation.

View File

@@ -1,28 +0,0 @@
# 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 the titlebar and content area on one shared full-window background layer; the custom titlebar must remain transparent and must not paint a contrasting strip.
- Avoid a visible titlebar bottom divider that makes the titlebar read as a separate color band.
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
- Keep `FANavigationView` pane and content template backgrounds transparent in the settings shell so the navigation control does not reintroduce a second surface color.
- 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.

View File

@@ -1,13 +0,0 @@
# 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.

View File

@@ -1,25 +0,0 @@
# 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 behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
## 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`.

View File

@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
- The project has switched back to signed FileMap incremental assets as the primary update path.
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
## Migration Note

View File

@@ -1,7 +0,0 @@
# 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.

View File

@@ -1,18 +0,0 @@
# 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.

View File

@@ -1,7 +0,0 @@
# 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.

View File

@@ -1,874 +0,0 @@
# 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<LoadedPlugin> LoadedPlugins { get; } // 已加载插件列表
public IReadOnlyList<PluginLoadResult> LoadResults { get; } // 加载结果列表
public IReadOnlyList<PluginCatalogEntry> Catalog { get; } // 插件目录
public IReadOnlyList<PluginSettingsSectionContribution> SettingsSections { get; } // 设置页贡献
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents { get; } // 组件贡献
public IReadOnlyList<PluginDesktopComponentEditorContribution> 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<PluginSharedContractReference>? 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<SettingsChangedEvent>? Changed; // 设置变更事件
```
**关键方法**
| 方法 | 签名 | 说明 |
|------|------|------|
| `LoadSnapshot` | `public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null)` | 加载设置快照 |
| `SaveSnapshot` | `public void SaveSnapshot<T>(SettingsScope scope, T snapshot, ...)` | 保存设置快照 |
| `LoadSection` | `public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, ...)` | 加载设置节 |
| `SaveSection` | `public void SaveSection<T>(SettingsScope scope, string subjectId, string sectionId, T section, ...)` | 保存设置节 |
| `GetValue` | `public T? GetValue<T>(SettingsScope scope, string key, ...)` | 获取单个值 |
| `SetValue` | `public void SetValue<T>(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<AppearanceThemeSnapshot>? 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 <path-to-plugin.laapp>
# 版本回退
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 AppBuilderBuildAvaloniaApp
进入 App.axaml.cs
├── 初始化主题ApplyThemeFromSettings
├── 初始化语言ApplyCurrentCultureFromSettings
├── 初始化设置窗口服务EnsureSettingsWindowService
├── 初始化天气定位刷新EnsureWeatherLocationRefreshService
└── 初始化通知服务EnsureNotificationService
框架初始化完成OnFrameworkInitializationCompleted
├── 初始化公共 IPCInitializePublicIpc
├── 启动单实例激活监听
├── 初始化 Launcher IPCInitializeLauncherIpcAsync
└── 初始化桌面壳层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 仓库代码和文档自动生成,如有更新请以仓库最新代码为准。*

View File

@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnetCampus.Ipc" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
using System.Threading.Tasks;
[IpcPublic]
public interface IMyService {
Task<MyResult> DoWork(MyRequest req);
}
public class MyResult { public string Msg {get;set;} }
public class MyRequest { public string Data {get;set;} }

View File

@@ -3,41 +3,40 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="12.0.3" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
<PackageVersion Include="Avalonia" Version="12.0.1" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.1" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<PackageVersion Include="Downloader" Version="5.4.0" />
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview4" />
<PackageVersion Include="Downloader" Version="4.1.1" />
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview1" />
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
<PackageVersion Include="Lib.Harmony.Thin" Version="2.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="Material.Avalonia" Version="3.16.0" />
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.9" />
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.9" />
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.8" />
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
<PackageVersion Include="PostHog" Version="2.7.1" />
<PackageVersion Include="Sentry" Version="6.5.0" />
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
<PackageVersion Include="PostHog" Version="2.4.0" />
<PackageVersion Include="Sentry" Version="4.0.0" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.0" />
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0-pre.4" />
<PackageVersion Include="YamlDotNet" Version="17.1.0" />
<PackageVersion Include="log4net" Version="3.3.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="log4net" Version="3.3.0" />
</ItemGroup>
</Project>

View File

@@ -1,82 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.AirAppHost.AirApp"
RequestedThemeVariant="Default">
<Application.Styles>
<sty:FluentAvaloniaTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="UserControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="TextBlock">
<Setter Property="FontFeatures" Value="tnum" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="SelectableTextBlock">
<Setter Property="FontFeatures" Value="tnum" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="ScrollViewer">
<Setter Property="ScrollViewer.IsScrollInertiaEnabled" Value="False" />
</Style>
<Style Selector="fi|SymbolIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="fi|SymbolIcon.icon-s, fi|FluentIcon.icon-s">
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="fi|SymbolIcon.icon-m, fi|FluentIcon.icon-m">
<Setter Property="FontSize" Value="16" />
</Style>
<Style Selector="fi|SymbolIcon.icon-l, fi|FluentIcon.icon-l">
<Setter Property="FontSize" Value="20" />
</Style>
</Application.Styles>
<Application.Resources>
<FontFamily x:Key="AppFontFamily">MiSans VF, avares://LanMountainDesktop.AirAppHost/Assets/Fonts#MiSans</FontFamily>
<Color x:Key="AirAppWindowBackgroundColor">#FFF7F9FC</Color>
<Color x:Key="AirAppWindowBorderColor">#22000000</Color>
<Color x:Key="AirAppTitleTextColor">#FF171A20</Color>
<Color x:Key="AirAppSecondaryTextColor">#FF657080</Color>
<Color x:Key="AirAppAccentColor">#FF2D73E5</Color>
<SolidColorBrush x:Key="AirAppWindowBackgroundBrush" Color="{StaticResource AirAppWindowBackgroundColor}" />
<SolidColorBrush x:Key="AirAppWindowBorderBrush" Color="{StaticResource AirAppWindowBorderColor}" />
<SolidColorBrush x:Key="AirAppTitleTextBrush" Color="{StaticResource AirAppTitleTextColor}" />
<SolidColorBrush x:Key="AirAppSecondaryTextBrush" Color="{StaticResource AirAppSecondaryTextColor}" />
<SolidColorBrush x:Key="AirAppAccentBrush" Color="{StaticResource AirAppAccentColor}" />
<SolidColorBrush x:Key="AdaptiveSurfaceRaisedBrush" Color="#FFF1F4F9" />
<SolidColorBrush x:Key="AdaptiveButtonBorderBrush" Color="#16000000" />
<SolidColorBrush x:Key="AdaptiveSurfaceBaseBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="SystemControlForegroundBaseMediumLowBrush" Color="#55000000" />
<SolidColorBrush x:Key="AdaptiveAccentBrush" Color="#FF2D73E5" />
<SolidColorBrush x:Key="AdaptiveOnAccentBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="AdaptiveTextPrimaryBrush" Color="#FF0F172A" />
<CornerRadius x:Key="DesignCornerRadiusComponent">18</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">10</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">8</CornerRadius>
</Application.Resources>
</Application>

View File

@@ -1,24 +0,0 @@
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();
}
}

View File

@@ -1,79 +0,0 @@
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<string> args)
{
var values = new Dictionary<string, string>(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<string, string> values, string key, string fallback)
{
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: fallback;
}
private static string? GetOptionalValue(IReadOnlyDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: null;
}
}

View File

@@ -1,16 +0,0 @@
<faWindowing:FAAppWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:faWindowing="using:FluentAvalonia.UI.Windowing"
x:Class="LanMountainDesktop.AirAppHost.AirAppWindow"
Width="520"
Height="360"
MinWidth="360"
MinHeight="260"
WindowStartupLocation="CenterScreen"
FontFamily="{DynamicResource AppFontFamily}"
Title="Air APP">
<Grid x:Name="WindowRoot"
Background="{DynamicResource AirAppWindowBackgroundBrush}">
<ContentControl x:Name="ContentHost" />
</Grid>
</faWindowing:FAAppWindow>

View File

@@ -1,274 +0,0 @@
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<IAirAppLifecycleService>();
_ = 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<IAirAppLifecycleService>();
_ = 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}";
}
}

View File

@@ -1,10 +0,0 @@
namespace LanMountainDesktop.AirAppHost;
public enum AirAppWindowChromeMode
{
Standard,
Borderless,
FullScreen,
Tool,
BackgroundOnly
}

View File

@@ -1,151 +0,0 @@
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);
}
}

View File

@@ -1,310 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.AirAppHost.ClockAirAppView">
<UserControl.Styles>
<Style Selector="Border.clock-card">
<Setter Property="Background" Value="#F7FFFFFF" />
<Setter Property="BorderBrush" Value="#16000000" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="18" />
</Style>
<Style Selector="ToggleButton.clock-tab">
<Setter Property="MinWidth" Value="84" />
<Setter Property="Height" Value="34" />
<Setter Property="Padding" Value="14,0" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource AirAppSecondaryTextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="ToggleButton.clock-tab:checked">
<Setter Property="Background" Value="{DynamicResource AirAppAccentBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.clock-command">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="14,6" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.clock-icon-command">
<Setter Property="Width" Value="34" />
<Setter Property="Height" Value="30" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style Selector="TextBlock.clock-muted">
<Setter Property="Foreground" Value="{DynamicResource AirAppSecondaryTextBrush}" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*"
RowSpacing="16"
Margin="22,14,22,20">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3"
VerticalAlignment="Center">
<TextBlock x:Name="HeaderTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AirAppTitleTextBrush}" />
<TextBlock x:Name="HeaderSubtitleTextBlock"
Classes="clock-muted"
FontSize="12" />
</StackPanel>
<Border Grid.Column="1"
Background="#0A000000"
CornerRadius="14"
Padding="4"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal"
Spacing="4">
<ToggleButton x:Name="WorldTabButton"
Classes="clock-tab"
Tag="world"
Click="OnTabButtonClick" />
<ToggleButton x:Name="StopwatchTabButton"
Classes="clock-tab"
Tag="stopwatch"
Click="OnTabButtonClick" />
<ToggleButton x:Name="TimerTabButton"
Classes="clock-tab"
Tag="timer"
Click="OnTabButtonClick" />
<ToggleButton x:Name="SettingsTabButton"
Classes="clock-tab"
Tag="settings"
Click="OnTabButtonClick" />
</StackPanel>
</Border>
</Grid>
<Grid Grid.Row="1">
<Grid x:Name="WorldPage"
ColumnDefinitions="1.05*,1.1*"
ColumnSpacing="16">
<Border Classes="clock-card">
<Grid RowDefinitions="Auto,*,Auto">
<TextBlock x:Name="LocalLabelTextBlock"
Classes="clock-muted"
FontSize="13"
FontWeight="SemiBold" />
<StackPanel Grid.Row="1"
Spacing="12"
VerticalAlignment="Center">
<TextBlock x:Name="LocalTimeTextBlock"
FontSize="54"
FontWeight="SemiBold"
LetterSpacing="0"
Foreground="{DynamicResource AirAppTitleTextBrush}" />
<TextBlock x:Name="LocalDateTextBlock"
Classes="clock-muted"
FontSize="15" />
<TextBlock x:Name="LocalTimeZoneTextBlock"
Classes="clock-muted"
FontSize="12"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock x:Name="WorldSummaryTextBlock"
Grid.Row="2"
Classes="clock-muted"
FontSize="12" />
</Grid>
</Border>
<Border Grid.Column="1"
Classes="clock-card">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="12">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="8">
<TextBox x:Name="TimeZoneSearchTextBox"
PlaceholderText="Search"
TextChanged="OnTimeZoneSearchChanged" />
<Button x:Name="AddCityButton"
Grid.Column="1"
Classes="clock-command"
Click="OnAddCityClick" />
</Grid>
<ComboBox x:Name="TimeZoneComboBox"
Grid.Row="1"
HorizontalAlignment="Stretch"
MaxDropDownHeight="280" />
<ScrollViewer Grid.Row="2"
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="WorldClockRowsPanel"
Spacing="8" />
</ScrollViewer>
</Grid>
</Border>
</Grid>
<Border x:Name="StopwatchPage"
Classes="clock-card">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="18">
<TextBlock x:Name="StopwatchHintTextBlock"
Classes="clock-muted"
FontSize="13" />
<StackPanel Grid.Row="1"
Spacing="18"
HorizontalAlignment="Center">
<TextBlock x:Name="StopwatchElapsedTextBlock"
Text="00:00:00.00"
FontSize="58"
FontWeight="SemiBold"
LetterSpacing="0"
Foreground="{DynamicResource AirAppTitleTextBrush}" />
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="10">
<Button x:Name="StopwatchStartPauseButton"
Classes="clock-command"
Click="OnStopwatchStartPauseClick" />
<Button x:Name="StopwatchLapButton"
Classes="clock-command"
Click="OnStopwatchLapClick" />
<Button x:Name="StopwatchResetButton"
Classes="clock-command"
Click="OnStopwatchResetClick" />
</StackPanel>
</StackPanel>
<ScrollViewer Grid.Row="2"
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="StopwatchLapsPanel"
Spacing="6" />
</ScrollViewer>
</Grid>
</Border>
<Border x:Name="TimerPage"
Classes="clock-card">
<Grid RowDefinitions="Auto,Auto,Auto,*"
RowSpacing="18">
<TextBlock x:Name="TimerHintTextBlock"
Classes="clock-muted"
FontSize="13" />
<TextBlock x:Name="TimerRemainingTextBlock"
Grid.Row="1"
Text="00:05:00"
FontSize="58"
FontWeight="SemiBold"
LetterSpacing="0"
Foreground="{DynamicResource AirAppTitleTextBrush}"
HorizontalAlignment="Center" />
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="8">
<Button Classes="clock-command"
Tag="1"
Click="OnTimerPresetClick">1</Button>
<Button Classes="clock-command"
Tag="5"
Click="OnTimerPresetClick">5</Button>
<Button Classes="clock-command"
Tag="10"
Click="OnTimerPresetClick">10</Button>
<Button Classes="clock-command"
Tag="15"
Click="OnTimerPresetClick">15</Button>
<Button Classes="clock-command"
Tag="30"
Click="OnTimerPresetClick">30</Button>
</StackPanel>
<Grid Grid.Row="3"
RowDefinitions="Auto,Auto,Auto"
RowSpacing="14"
HorizontalAlignment="Center">
<StackPanel Orientation="Horizontal"
Spacing="8"
HorizontalAlignment="Center">
<TextBox x:Name="TimerMinutesTextBox"
Width="120"
PlaceholderText="Minutes"
Text="5" />
<Button x:Name="TimerApplyButton"
Classes="clock-command"
Click="OnTimerApplyClick" />
</StackPanel>
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="10">
<Button x:Name="TimerStartPauseButton"
Classes="clock-command"
Click="OnTimerStartPauseClick" />
<Button x:Name="TimerResetButton"
Classes="clock-command"
Click="OnTimerResetClick" />
</StackPanel>
<TextBlock x:Name="TimerStatusTextBlock"
Grid.Row="2"
Classes="clock-muted"
FontSize="13"
HorizontalAlignment="Center" />
</Grid>
</Grid>
</Border>
<Border x:Name="SettingsPage"
Classes="clock-card">
<StackPanel Spacing="18"
MaxWidth="560"
HorizontalAlignment="Left">
<TextBlock x:Name="SettingsHeaderTextBlock"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AirAppTitleTextBrush}" />
<Grid ColumnDefinitions="220,*"
RowDefinitions="Auto,Auto,Auto,Auto"
RowSpacing="14"
ColumnSpacing="18">
<TextBlock x:Name="TimeFormatLabelTextBlock"
Classes="clock-muted"
VerticalAlignment="Center" />
<ComboBox x:Name="TimeFormatComboBox"
Grid.Column="1"
SelectionChanged="OnSettingsChanged" />
<TextBlock x:Name="StartupTabLabelTextBlock"
Grid.Row="1"
Classes="clock-muted"
VerticalAlignment="Center" />
<ComboBox x:Name="StartupTabComboBox"
Grid.Row="1"
Grid.Column="1"
SelectionChanged="OnSettingsChanged" />
<CheckBox x:Name="ShowSecondsCheckBox"
Grid.Row="2"
Grid.ColumnSpan="2"
IsCheckedChanged="OnSettingsChanged" />
<CheckBox x:Name="ActivateOnTimerFinishedCheckBox"
Grid.Row="3"
Grid.ColumnSpan="2"
IsCheckedChanged="OnSettingsChanged" />
</Grid>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>

View File

@@ -1,665 +0,0 @@
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<TimeZoneInfo> _allTimeZones;
private readonly List<WorldClockRowVisual> _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<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, selectedId, StringComparison.OrdinalIgnoreCase))
?? comboBox.Items.OfType<ComboBoxItem>().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<ComboBoxItem>().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);
}
}

View File

@@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="..\LanMountainDesktop\Assets\Fonts\**" Link="Assets\Fonts\%(RecursiveDir)%(Filename)%(Extension)" />
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
<None Include="..\LanMountainDesktop\Localization\*.json"
Link="Localization\%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj"
AdditionalProperties="SkipAirAppHostBuild=true" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
</ItemGroup>
</Project>

View File

@@ -1,53 +0,0 @@
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<AirApp>()
.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();
};
}
}

View File

@@ -1,39 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.AirAppHost.WorldClockAirAppView">
<Grid RowDefinitions="*,Auto"
Margin="18,0,18,16">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<TextBlock x:Name="TimeTextBlock"
Text="00:00:00"
FontSize="42"
FontWeight="SemiBold"
LetterSpacing="0"
Foreground="{DynamicResource AirAppTitleTextBrush}"
HorizontalAlignment="Center" />
<TextBlock x:Name="DateTextBlock"
Text="0000-00-00"
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource AirAppSecondaryTextBrush}"
HorizontalAlignment="Center" />
<TextBlock x:Name="TimeZoneTextBlock"
Text="Local Time"
FontSize="12"
Foreground="{DynamicResource AirAppSecondaryTextBrush}"
HorizontalAlignment="Center" />
</StackPanel>
<Border Grid.Row="1"
HorizontalAlignment="Center"
Padding="12,7"
CornerRadius="999"
Background="#112D73E5">
<TextBlock x:Name="SessionTextBlock"
FontSize="11"
Foreground="{DynamicResource AirAppAccentBrush}" />
</Border>
</Grid>
</UserControl>

View File

@@ -1,52 +0,0 @@
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;
}
}

View File

@@ -1,95 +0,0 @@
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppHostLocator
{
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
private static string ExecutableName => OperatingSystem.IsWindows()
? WindowsExecutableName
: UnixExecutableName;
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<string> EnumerateCandidates(string? packageRoot, string? hostPath)
{
foreach (var root in EnumerateRoots(packageRoot, hostPath))
{
yield return Path.Combine(root, "AirAppHost", ExecutableName);
yield return Path.Combine(root, "AirAppHost", DllName);
yield return Path.Combine(root, ExecutableName);
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", ExecutableName);
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
yield return Path.Combine(deploymentDirectory, ExecutableName);
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",
ExecutableName);
yield return Path.Combine(
current.FullName,
"LanMountainDesktop.AirAppHost",
"bin",
#if DEBUG
"Debug",
#else
"Release",
#endif
"net10.0",
DllName);
}
}
private static IEnumerable<string> 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, ".."));
}
}

View File

@@ -1,22 +0,0 @@
namespace LanMountainDesktop.AirAppRuntime;
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();
}
}

View File

@@ -1,330 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
{
private readonly object _gate = new();
private readonly IAirAppProcessStarter _processStarter;
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
{
_processStarter = processStarter;
}
public Task<AirAppOperationResult> OpenAsync(AirAppOpenRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var appId = Normalize(request.AppId, "unknown");
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
AirAppRuntimeLogger.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;
AirAppRuntimeLogger.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)
{
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
}
}
}
public Task<AirAppOperationResult> 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<AirAppOperationResult> 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<AirAppInstanceInfo[]> GetInstancesAsync()
{
lock (_gate)
{
CleanupExitedInstances();
return Task.FromResult(_instances.Values.Select(static instance => instance.ToInfo()).ToArray());
}
}
public Task<AirAppOperationResult> 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;
AirAppRuntimeLogger.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<AirAppOperationResult> UnregisterAsync(string instanceKey, int processId)
{
lock (_gate)
{
if (_instances.TryGetValue(instanceKey, out var instance) &&
(processId <= 0 || instance.ProcessId == processId))
{
_instances.Remove(instanceKey);
AirAppRuntimeLogger.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);
AirAppRuntimeLogger.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;
}
}
internal 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);
}
}
}

View File

@@ -1,29 +0,0 @@
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeControlService : IAirAppRuntimeControlService
{
private readonly AirAppRuntimeLifetime _lifetime;
public AirAppRuntimeControlService(AirAppRuntimeLifetime lifetime)
{
_lifetime = lifetime;
}
public Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
{
_lifetime.AttachHost(hostProcessId);
var status = _lifetime.GetStatus();
return Task.FromResult(new AirAppRuntimeControlResult(
hostProcessId > 0,
hostProcessId > 0 ? "host_attached" : "invalid_host_pid",
hostProcessId > 0 ? "AirApp runtime host process attached." : "Host process id must be positive.",
status));
}
public Task<AirAppRuntimeStatus> GetStatusAsync()
{
return Task.FromResult(_lifetime.GetStatus());
}
}

View File

@@ -1,29 +0,0 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeIpcHost : IDisposable
{
private readonly PublicIpcHostService _host;
public AirAppRuntimeIpcHost(
AirAppLifecycleService lifecycleService,
AirAppRuntimeControlService controlService)
{
_host = new PublicIpcHostService(IpcConstants.AirAppRuntimePipeName);
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
_host.RegisterPublicService<IAirAppRuntimeControlService>(controlService);
}
public void Start()
{
_host.Start();
AirAppRuntimeLogger.Info($"Air APP runtime IPC started. Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
}
public void Dispose()
{
_host.Dispose();
}
}

View File

@@ -1,77 +0,0 @@
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed class AirAppRuntimeLifetime
{
private readonly object _gate = new();
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
private readonly AirAppLifecycleService _lifecycleService;
private readonly int _launcherProcessId;
private readonly int _requesterProcessId;
private int _hostProcessId;
private DateTimeOffset _updatedAtUtc;
public AirAppRuntimeLifetime(AirAppRuntimeOptions options, AirAppLifecycleService lifecycleService)
{
_lifecycleService = lifecycleService;
_launcherProcessId = options.LauncherProcessId;
_requesterProcessId = options.RequesterProcessId;
_hostProcessId = options.RequesterProcessId;
_updatedAtUtc = _startedAtUtc;
}
public void AttachHost(int hostProcessId)
{
if (hostProcessId <= 0)
{
return;
}
lock (_gate)
{
_hostProcessId = hostProcessId;
_updatedAtUtc = DateTimeOffset.UtcNow;
}
AirAppRuntimeLogger.Info($"Attached host process. HostPid={hostProcessId}.");
}
public bool ShouldKeepAlive()
{
var status = GetStatus();
return status.LauncherProcessAlive ||
status.HostProcessAlive ||
IsProcessAlive(_requesterProcessId) ||
status.HasLiveAirApps;
}
public AirAppRuntimeStatus GetStatus()
{
int hostPid;
DateTimeOffset updatedAt;
lock (_gate)
{
hostPid = _hostProcessId;
updatedAt = _updatedAtUtc;
}
var launcherAlive = IsProcessAlive(_launcherProcessId);
var hostAlive = IsProcessAlive(hostPid);
var hasLiveAirApps = _lifecycleService.HasLiveAirApps();
return new AirAppRuntimeStatus(
Environment.ProcessId,
_launcherProcessId,
hostPid,
launcherAlive,
hostAlive,
hasLiveAirApps,
_startedAtUtc,
updatedAt);
}
internal static bool IsProcessAlive(int processId)
{
return AirAppLifecycleService.IsProcessAlive(processId);
}
}

View File

@@ -1,16 +0,0 @@
using System.Diagnostics;
namespace LanMountainDesktop.AirAppRuntime;
internal static class AirAppRuntimeLogger
{
public static void Info(string message) => Trace.WriteLine($"[AirAppRuntime] INFO {message}");
public static void Warn(string message) => Trace.WriteLine($"[AirAppRuntime] WARN {message}");
public static void Warn(string message, Exception ex) =>
Trace.WriteLine($"[AirAppRuntime] WARN {message} {ex}");
public static void Error(string message, Exception ex) =>
Trace.WriteLine($"[AirAppRuntime] ERROR {message} {ex}");
}

View File

@@ -1,66 +0,0 @@
using System.Globalization;
namespace LanMountainDesktop.AirAppRuntime;
internal sealed record AirAppRuntimeOptions(
string? AppRoot,
string? DataRoot,
int LauncherProcessId,
int RequesterProcessId)
{
public static AirAppRuntimeOptions Parse(IReadOnlyList<string> args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < args.Count; index++)
{
var current = args[index];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
continue;
}
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++index];
}
else
{
values[key] = "true";
}
}
return new AirAppRuntimeOptions(
GetOptionalPath(values, "app-root"),
GetOptionalPath(values, "data-root"),
GetInt(values, "launcher-pid"),
GetInt(values, "requester-pid"));
}
private static string? GetOptionalPath(IReadOnlyDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? Path.GetFullPath(value)
: null;
}
private static int GetInt(IReadOnlyDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) &&
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
}
}

View File

@@ -1,93 +0,0 @@
using System.Diagnostics;
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.AirAppRuntime;
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<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider;
public AirAppProcessStarter(
AirAppHostLocator locator,
Func<string?> packageRootProvider,
Func<string?> hostPathProvider,
Func<string?> 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 = CreateStartInfo(hostPath);
AddArgument(startInfo, "--app-id", appId);
AddArgument(startInfo, "--session-id", sessionId);
AddArgument(startInfo, "--instance-key", instanceKey);
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
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());
}
AirAppRuntimeLogger.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
{
AirAppRuntimeLogger.Info(
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
}
catch (Exception ex)
{
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
}
};
}
return process;
}
internal static ProcessStartInfo CreateStartInfo(string hostPath)
{
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
}
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{
startInfo.ArgumentList.Add(name);
startInfo.ArgumentList.Add(value);
}
}

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>false</PublishAot>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,40 +0,0 @@
namespace LanMountainDesktop.AirAppRuntime;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
var options = AirAppRuntimeOptions.Parse(args);
AirAppRuntimeLogger.Info(
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
try
{
var lifecycleService = new AirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => options.AppRoot,
() => null,
() => options.DataRoot));
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
var controlService = new AirAppRuntimeControlService(lifetime);
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
ipcHost.Start();
while (lifetime.ShouldKeepAlive())
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
return 0;
}
catch (Exception ex)
{
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
return 1;
}
}
}

View File

@@ -1,3 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -38,15 +38,6 @@ public static class AppearanceCornerRadiusTokenFactory
Xl: new CornerRadius(40),
Island: new CornerRadius(44),
Component: new CornerRadius(32)),
GlobalAppearanceSettings.CornerRadiusStyleFluent => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(2),
Xs: new CornerRadius(4),
Sm: new CornerRadius(4),
Md: new CornerRadius(8),
Lg: new CornerRadius(8),
Xl: new CornerRadius(12),
Island: new CornerRadius(16),
Component: new CornerRadius(8)),
// Balanced (default)
_ => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(6),

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