mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
76 Commits
Avalonia12
...
v0.8.7.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8403b89a15 | ||
|
|
0ea98c08bf | ||
|
|
54d97e312d | ||
|
|
04b95020bd | ||
|
|
cf08269e15 | ||
|
|
03e4442e74 | ||
|
|
0c8830133a | ||
|
|
131043fe37 | ||
|
|
a2ac302ee7 | ||
|
|
c351a8e7f3 | ||
|
|
21e970c5b6 | ||
|
|
17873f0f43 | ||
|
|
4051b5cd74 | ||
|
|
5be4537b2c | ||
|
|
c5e75244af | ||
|
|
6a650873bc | ||
|
|
d004088601 | ||
|
|
a1cc0ee2bf | ||
|
|
313d093257 | ||
|
|
1ef47c780b | ||
|
|
a26b6faace | ||
|
|
b219f109ec | ||
|
|
1ee6e68f33 | ||
|
|
545dee85a7 | ||
|
|
ebe35d6f91 | ||
|
|
63f08987a7 | ||
|
|
ce41fd676c | ||
|
|
c1f148f7d6 | ||
|
|
a75ed0ced1 | ||
|
|
2dc40c53e2 | ||
|
|
a99ed9fef2 | ||
|
|
553cee54f9 | ||
|
|
1d7a878d55 | ||
|
|
0361b83ea2 | ||
|
|
cc85638a37 | ||
|
|
791e38d55e | ||
|
|
75aed3f6ad | ||
|
|
01cf32a610 | ||
|
|
69bcf2c6eb | ||
|
|
12f0caafc7 | ||
|
|
fd3a193e68 | ||
|
|
edf3d82cc9 | ||
|
|
e1adba3771 | ||
|
|
ac8ee8dc54 | ||
|
|
7a70476ce8 | ||
|
|
cc1c040203 | ||
|
|
68dc17f863 | ||
|
|
b6d820a320 | ||
|
|
93758fc083 | ||
|
|
9404a0b347 | ||
|
|
a5abda62dc | ||
|
|
ada0cd4a3a | ||
|
|
b48056391a | ||
|
|
33c264f6dd | ||
|
|
563f12caa1 | ||
|
|
f0319b7deb | ||
|
|
d8f75e86be | ||
|
|
84caca02bf | ||
|
|
aa7e15d967 | ||
|
|
6b1c738d8c | ||
|
|
f8a4bb888c | ||
|
|
b71687cecd | ||
|
|
68ca532dc0 | ||
|
|
60e7f31ba7 | ||
|
|
574b798092 | ||
|
|
49bbae29af | ||
|
|
1d7df5a105 | ||
|
|
6a30bc6fce | ||
|
|
3a8516334a | ||
|
|
458494d131 | ||
|
|
01670147f6 | ||
|
|
0348324fa3 | ||
|
|
fc4d0c4cd8 | ||
|
|
eb066b53f1 | ||
|
|
5ea242af9a | ||
|
|
abfa64b3d7 |
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"diffEditor.renderSideBySide": false
|
||||
"diffEditor.renderSideBySide": false,
|
||||
"clawMode.mode": "editor",
|
||||
"workbench.activityBar.location": "default"
|
||||
}
|
||||
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")",
|
||||
"Read(//d/github/**)",
|
||||
"Bash(dotnet build *)",
|
||||
"Bash(dotnet test *)",
|
||||
"Bash(python -)",
|
||||
"Bash(py -3 -c \"from pathlib import Path; p=Path\\(r'd:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs'\\); t=p.read_text\\(encoding='utf-8'\\); s=t.find\\('public sealed partial class UpdateSettingsPageViewModel : ViewModelBase'\\); e=t.find\\('public sealed partial class StudySettingsPageViewModel : ViewModelBase', s\\); assert s!=-1 and e!=-1; p.write_text\\(t[:s]+t[e:], encoding='utf-8'\\); print\\('ok'\\)\")",
|
||||
"Bash(perl -0777 -i -pe \"s/public sealed partial class UpdateSettingsPageViewModel : ViewModelBase\\\\R\\\\{.*?\\\\R\\\\}\\\\R\\\\Rpublic sealed partial class StudySettingsPageViewModel : ViewModelBase/public sealed partial class StudySettingsPageViewModel : ViewModelBase/s\" \"d:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
291
.comate/specs/standby-digital-clock/doc.md
Normal file
291
.comate/specs/standby-digital-clock/doc.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# StandBy Digital Clock - iPhone 待机风格大数字时钟组件
|
||||
|
||||
## 1. 需求场景与处理逻辑
|
||||
|
||||
### 1.1 需求描述
|
||||
新增一个 4×2 尺寸的数字时钟桌面组件,视觉风格参考 iPhone 横屏充电时的 StandBy 待机显示——大面积、粗体、圆润的数字显示当前时间(HH:MM),数字采用不规则的自由排版(有微妙的垂直偏移,不在一条直线上),颜色使用 Monet 主题色而非纯黑/白,伴随数字切换时的流畅垂直滚动/翻转动画,下方显示日期信息。
|
||||
|
||||
### 1.2 用户体验目标
|
||||
- 大字号、圆润粗体的数字时间,远距离一目了然
|
||||
- 数字采用不规则自由排版(微妙垂直偏移),营造 iPhone StandBy 那种有机、散漫的视觉节奏
|
||||
- 数字使用 Monet 主题色(跟随壁纸/用户选色的强调色),而非死板的纯黑/白
|
||||
- 数字变化时执行垂直滑动动画(旧数字向上滑出,新数字从下方滑入),类似翻页时钟效果
|
||||
- 冒号(:)有呼吸闪烁效果
|
||||
- 支持夜间/日间模式自动切换
|
||||
- 点击组件可打开世界时钟 AirApp
|
||||
- 支持时区配置(与现有桌面时钟共享设置体系)
|
||||
|
||||
### 1.3 处理逻辑
|
||||
1. 组件加载时读取时区设置和秒针模式设置
|
||||
2. `DispatcherTimer` 每秒触发一次更新
|
||||
3. 当检测到分钟数变化时,触发分钟数字的垂直滑动动画
|
||||
4. 当检测到小时数变化时,触发小时数字的垂直滑动动画
|
||||
5. 冒号以 1 秒周期做透明度脉冲动画
|
||||
6. 每 tick 检查是否需要切换日间/夜间视觉模式
|
||||
|
||||
## 2. 架构与技术方案
|
||||
|
||||
### 2.1 组件架构
|
||||
遵循现有桌面组件架构模式:
|
||||
- 继承 `UserControl`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware`
|
||||
- AXAML 定义根布局结构,代码后置处理动画逻辑
|
||||
- 通过 `DesktopComponentDefinition` 注册到组件系统
|
||||
|
||||
### 2.2 数字滚动动画技术方案
|
||||
采用 Avalonia `RenderTransform` + `DoubleTransition` 实现数字滚动:
|
||||
|
||||
**核心思路**:每个数位(共 4 位:H1, H2, M1, M2)使用 `ClipToBounds` 的容器,内含一个垂直排列的 `StackPanel`,包含当前数字和下一个数字。切换时通过 `TranslateTransform.Y` 的 `DoubleTransition` 实现平滑滚动。
|
||||
|
||||
```
|
||||
每位数字的结构:
|
||||
┌─ DigitClip (ClipToBounds=true) ──────────┐
|
||||
│ ┌─ DigitStack (TranslateTransform.Y) ──┐ │
|
||||
│ │ [当前数字 TextBlock] │ │
|
||||
│ │ [新数字 TextBlock] │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
当数字变化时:
|
||||
1. 在 StackPanel 底部添加新数字的 TextBlock
|
||||
2. 将 `TranslateTransform.Y` 从 0 动画过渡到 `-digitHeight`
|
||||
3. 动画完成后移除旧数字,重置 Y 为 0
|
||||
|
||||
### 2.3 动画参数
|
||||
- 使用项目 `FluttermotionToken` 体系:滚动动画时长 `FluttermotionToken.Standard`(200ms)
|
||||
- 缓动函数:`CubicEaseOut`(与项目现有动画风格一致)
|
||||
- 冒号呼吸动画:透明度 1.0 → 0.3 → 1.0,周期 2 秒,使用 `DoubleTransition`
|
||||
|
||||
### 2.4 尺寸与布局
|
||||
- 组件定义:`MinWidthCells = 4, MinHeightCells = 2`
|
||||
- 缩放规则:2:1 比例(与 WorldClock 一致)
|
||||
- 内部布局采用 `Viewbox` 包裹,确保在不同 cellSize 下自适应缩放
|
||||
- 数字字体大小:基准设计为 130px(在 Viewbox 内),实际显示由 Viewbox 缩放
|
||||
|
||||
### 2.5 布局风格——不规则自由排版(iPhone StandBy 风格)
|
||||
iPhone StandBy 的数字不是规矩地排成一条直线,而是有微妙的垂直偏移和大小差异,营造出自由散漫、有机的视觉节奏:
|
||||
|
||||
```
|
||||
H1 H2 : M1 M2
|
||||
↗↘ ↘↗ ↗↘ ↘↗
|
||||
↕+6 ↕+2 : ↕+4 ↕+2
|
||||
↖ -3° ↗ +4° : ↖ -1° ↗ +5°
|
||||
←+6,↑-10 ←-2,↓+10 →+4,↑-3 ←-2,↓+12
|
||||
```
|
||||
|
||||
每个数字有三个自由度:
|
||||
- **垂直偏移 (Y)**:H1=-10, H2=+10, 冒号=+8, M1=-3, M2=+12
|
||||
- **水平偏移 (X)**:H1=+6, H2=-2, 冒号=0, M1=+4, M2=-2
|
||||
- **旋转角度 (Z)**:H1=-4°, H2=+3°, 冒号=-1°, M1=-2°, M2=+5°
|
||||
|
||||
### 2.6 视觉风格——圆润粗体 + Monet 主题色
|
||||
- **字体**:`FontWeight.Bold`,配合较大的字号,视觉上圆润饱满
|
||||
- **颜色**:使用项目 Monet 主题色系统,数字颜色跟随 `AdaptiveAccentBrush` / `SystemAccentColor`,而非纯黑/白
|
||||
- 数字颜色通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
|
||||
- 跟随系统:使用 `AdaptiveAccentBrush`(Monet 提取的强调色)
|
||||
- 原生模式:使用组件自带的特色色彩
|
||||
- 夜间模式:深色渐变背景 + 主题色数字(亮色调)
|
||||
- 日间模式:浅色渐变背景 + 主题色数字(深色调)
|
||||
- 夜间暗光环境:数字过渡到柔和的红色调(`#FF6B4A`),模拟 iPhone StandBy 夜间红色调
|
||||
- **冒号颜色**:与数字同色,但有呼吸动画
|
||||
- **日期行**:使用 `AdaptiveTextMutedBrush`(跟随主题的弱化文字色),字号约 14-16px 基准
|
||||
- **根容器圆角**:`DesignCornerRadiusComponent`(遵循圆角规范)
|
||||
|
||||
## 3. 受影响文件
|
||||
|
||||
### 3.1 新增文件
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | 新增 | 组件 AXAML 布局 |
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 新增 | 组件代码后置(动画逻辑、时间更新、模式切换) |
|
||||
|
||||
### 3.2 修改文件
|
||||
| 文件 | 修改类型 | 受影响函数/区域 |
|
||||
|------|----------|-----------------|
|
||||
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增常量 | 新增 `DesktopStandbyDigitalClock` 常量 |
|
||||
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 新增定义 | `CreateDefault()` 中新增组件定义 |
|
||||
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增运行时注册 | `GetDefaultRegistrations()` 中新增运行时注册项 |
|
||||
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | 新增缩放规则 | `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 |
|
||||
|
||||
## 4. 实现细节
|
||||
|
||||
### 4.1 BuiltInComponentIds 新增常量
|
||||
```csharp
|
||||
public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
|
||||
```
|
||||
|
||||
### 4.2 ComponentRegistry 新增定义
|
||||
```csharp
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
||||
"StandBy Clock",
|
||||
"Clock",
|
||||
"Clock",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
```
|
||||
|
||||
### 4.3 DesktopComponentRuntimeRegistry 新增注册
|
||||
```csharp
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
||||
"component.standby_digital_clock",
|
||||
() => new StandbyDigitalClockWidget()),
|
||||
```
|
||||
|
||||
### 4.4 NormalizeAspectRatioForComponent 缩放规则
|
||||
在 `case BuiltInComponentIds.DesktopWorldClock:` 的同一分支中添加 `BuiltInComponentIds.DesktopStandbyDigitalClock`,使用 2:1 比例规则。
|
||||
|
||||
### 4.5 AXAML 布局结构
|
||||
```xml
|
||||
<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 比例规则)
|
||||
52
.comate/specs/standby-digital-clock/summary.md
Normal file
52
.comate/specs/standby-digital-clock/summary.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# StandBy Digital Clock 实现总结
|
||||
|
||||
## 完成状态
|
||||
全部任务已完成,构建通过(0 错误)。
|
||||
|
||||
## 变更清单
|
||||
|
||||
### 新增文件
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期,Monet 主题色绑定 |
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置:数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 |
|
||||
|
||||
### 修改文件
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 |
|
||||
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 |
|
||||
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 |
|
||||
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 |
|
||||
|
||||
## 核心设计要点
|
||||
|
||||
### 不规则自由排版(iPhone StandBy 风格)
|
||||
- 每个数字有独立的垂直 Margin 偏移(H1 上移10, H2 下移8, M1 上移5, M2 下移10)
|
||||
- 冒号比数字中心略低(下移6)
|
||||
- 数字间距不等,营造自由散漫的视觉节奏
|
||||
|
||||
### Monet 主题色
|
||||
- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色
|
||||
- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
|
||||
- 跟随系统:使用 Monet 提取的强调色
|
||||
- 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy
|
||||
- 日期文本使用 `AdaptiveTextMutedBrush`
|
||||
|
||||
### 数字滚动动画
|
||||
- `TranslateTransform.Y` + `DoubleTransition`(200ms CubicEaseOut)
|
||||
- 动画完成后清理旧 TextBlock 并重置 transform
|
||||
|
||||
### 冒号呼吸
|
||||
- 每秒切换 Opacity(1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡
|
||||
|
||||
### 日/夜模式
|
||||
- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算
|
||||
- 夜间:深色渐变背景 + 亮调强调色数字
|
||||
- 日间:浅色渐变背景 + 深调强调色数字
|
||||
|
||||
### 组件规格
|
||||
- 尺寸:4×2 (MinWidthCells=4, MinHeightCells=2)
|
||||
- 分类:Clock
|
||||
- 缩放:2:1 比例 (Proportional)
|
||||
- 字体:FontWeight.Bold, 120px 基准
|
||||
25
.comate/specs/standby-digital-clock/tasks.md
Normal file
25
.comate/specs/standby-digital-clock/tasks.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# StandBy Digital Clock 任务计划
|
||||
|
||||
- [x] Task 1: 注册组件定义与运行时
|
||||
- 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量
|
||||
- 1.2: 在 `ComponentRegistry.cs` 的 `CreateDefault()` 中新增 `DesktopComponentDefinition`(4×2, Clock 分类, Proportional)
|
||||
- 1.3: 在 `DesktopComponentRuntimeRegistry.cs` 的 `GetDefaultRegistrations()` 中新增运行时注册项
|
||||
- 1.4: 在 `MainWindow.ComponentSystem.cs` 的 `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则
|
||||
|
||||
- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局
|
||||
- 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 RootBorder(DesignCornerRadiusComponent)、Viewbox、时间数字区域(4 个 ClipToBounds 数位容器 + 冒号)、日期文本
|
||||
- 2.2: 确保 Viewbox 内基准设计尺寸为 400×200,数字使用 FontWeight.Bold,冒号和日期布局合理
|
||||
|
||||
- [x] Task 3: 实现组件代码后置(核心逻辑与动画)
|
||||
- 3.1: 创建 `StandbyDigitalClockWidget.axaml.cs`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware` 接口
|
||||
- 3.2: 实现 DispatcherTimer 每秒更新逻辑,比较新旧时间数字,触发数位滚动动画
|
||||
- 3.3: 实现数字垂直滚动动画:每位数字使用 TranslateTransform.Y + DoubleTransition,旧数字上滑出新数字滑入,动画完成后清理
|
||||
- 3.4: 实现冒号呼吸动画:每秒切换透明度,配合 DoubleTransition 平滑过渡
|
||||
- 3.5: 实现日间/夜间模式切换:检测 ActualThemeVariant 和亮度,切换背景渐变和数字颜色;夜间暗光环境过渡到红色调
|
||||
- 3.6: 实现 ApplyCellSize 缩放逻辑,clamp 缩放因子,更新圆角和间距
|
||||
- 3.7: 实现时区设置加载(复用 AnalogClockWidget 逻辑),点击打开世界时钟 AirApp
|
||||
- 3.8: 实现日期文本更新逻辑,显示完整日期和星期
|
||||
|
||||
- [x] Task 4: 构建验证与调试
|
||||
- 4.1: 执行 `dotnet build` 确保编译通过,修复所有错误
|
||||
- 4.2: 检查圆角规范合规性(根容器使用 DesignCornerRadiusComponent)
|
||||
432
.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md
Normal file
432
.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md
Normal file
@@ -0,0 +1,432 @@
|
||||
---
|
||||
name: Launcher 单项目解耦
|
||||
overview: 在保持单一 LanMountainDesktop.Launcher 项目、单一 exe、零部署风险的前提下,按职责域增量重构:目录分层、RunAsync→Pipeline+Phase、UpdateEngine→策略类、App→纯 Avalonia+LauncherOrchestrator;执行过程中由 Agent 自主 Git 提交,每域可编译可测。
|
||||
todos:
|
||||
- id: phase-a-diagnostics
|
||||
content: Phase A:Startup 诊断 + 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 B2:RunAsync→LaunchPipeline+ILaunchPhase,引入 LauncherOrchestrator,删除 LauncherFlowCoordinator,提交
|
||||
status: completed
|
||||
- id: phase-b-app-slim
|
||||
content: Phase B3:App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator,提交
|
||||
status: completed
|
||||
- id: phase-c-di
|
||||
content: Phase C:LauncherServiceRegistration + 轻量 MS DI,统一 CLI/GUI 装配,提交
|
||||
status: completed
|
||||
- id: phase-d-update-split
|
||||
content: Phase D:UpdateEngineService→门面+策略类(Verifier/Activator/Rollback 等),提交
|
||||
status: completed
|
||||
- id: phase-e-guardrails
|
||||
content: Phase E:LauncherArchitectureTests + 文档 + 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 A:Startup 子系统 + 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 B2:Pipeline + Phase + LauncherOrchestrator
|
||||
|
||||
- 实现 `ILaunchPhase`、`LaunchPipeline`、`LauncherOrchestrator`
|
||||
- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试)
|
||||
- 删除 `LauncherFlowCoordinator*`
|
||||
- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline`
|
||||
|
||||
### Phase B3:App.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 D:UpdateEngine 策略拆分(可与 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 本地 smoke:launch / 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. 明确不做
|
||||
|
||||
- 不新建 csproj(Launcher.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
|
||||
|
||||
1
.cursor/skills/.gitkeep
Normal file
1
.cursor/skills/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
166
.github/workflows/ddss-publish.yml
vendored
166
.github/workflows/ddss-publish.yml
vendored
@@ -1,166 +0,0 @@
|
||||
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
235
.github/workflows/plonds-build.yml
vendored
@@ -1,235 +0,0 @@
|
||||
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
|
||||
258
.github/workflows/plonds-comparator.yml
vendored
Normal file
258
.github/workflows/plonds-comparator.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
||||
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
|
||||
138
.github/workflows/plonds-uploader.yml
vendored
Normal file
138
.github/workflows/plonds-uploader.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
name: PLONDS Publisher
|
||||
|
||||
concurrency:
|
||||
group: plonds-publish-${{ 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'
|
||||
PLONDS_S3_PREFIX: lanmountain/update/plonds
|
||||
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
|
||||
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
|
||||
PLONDS_S3_MULTIPART_THRESHOLD_MB: '8'
|
||||
PLONDS_S3_MULTIPART_PART_SIZE_MB: '5'
|
||||
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
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
|
||||
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||
echo "RELEASE_TAG=${TAG}" >> "$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: Download PLONDS release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf plonds-assets
|
||||
mkdir -p plonds-assets
|
||||
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -p files-windows-x64.zip -D plonds-assets --clobber
|
||||
test -f plonds-assets/changed.zip
|
||||
test -f plonds-assets/PLONDS.json
|
||||
test -f plonds-assets/files-windows-x64.zip
|
||||
jq -e . plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Publish PLONDS assets to Rainyun S3
|
||||
env:
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
|
||||
echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGION="${S3_REGION:-us-east-1}"
|
||||
PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
|
||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||
publish-s3 \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--manifest "$PWD/plonds-assets/PLONDS.json" \
|
||||
--changed-zip "$PWD/plonds-assets/changed.zip" \
|
||||
--files-zip "$PWD/plonds-assets/files-windows-x64.zip" \
|
||||
--work-dir "$PWD/plonds-publish-work" \
|
||||
--s3-prefix "$PLONDS_S3_PREFIX" \
|
||||
--s3-endpoint "$S3_ENDPOINT" \
|
||||
--s3-region "$REGION" \
|
||||
--s3-bucket "$S3_BUCKET" \
|
||||
--s3-access-key "$S3_ACCESS_KEY" \
|
||||
--s3-secret-key "$S3_SECRET_KEY" \
|
||||
--s3-public-base-url "$PUBLIC_BASE" \
|
||||
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
|
||||
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY" \
|
||||
--multipart-threshold-mb "$PLONDS_S3_MULTIPART_THRESHOLD_MB" \
|
||||
--multipart-part-size-mb "$PLONDS_S3_MULTIPART_PART_SIZE_MB" \
|
||||
--multipart-concurrency "$PLONDS_S3_MULTIPART_CONCURRENCY"
|
||||
|
||||
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Upload enriched PLONDS manifest to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --clobber
|
||||
217
.github/workflows/release.yml
vendored
217
.github/workflows/release.yml
vendored
@@ -98,10 +98,8 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
self_contained: true
|
||||
suffix: ''
|
||||
- arch: x86
|
||||
self_contained: true
|
||||
suffix: ''
|
||||
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
||||
|
||||
@@ -167,48 +165,80 @@ jobs:
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
||||
$publishDir = "publish/windows-${{ matrix.arch }}"
|
||||
|
||||
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 }}
|
||||
}
|
||||
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 }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||
$appDir = "app-$version"
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
|
||||
@@ -220,13 +250,51 @@ 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
|
||||
@@ -238,8 +306,7 @@ jobs:
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$suffix = "${{ matrix.suffix }}"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$outputDir = "build-installer"
|
||||
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
|
||||
|
||||
@@ -273,7 +340,6 @@ jobs:
|
||||
"/DMyOutputDir=$outputDir",
|
||||
"/DMyAppArch=$arch",
|
||||
"/DMyAppSuffix=$suffix",
|
||||
"/DIsSelfContained=$selfContained",
|
||||
$installerScript
|
||||
)
|
||||
|
||||
@@ -294,7 +360,7 @@ jobs:
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||
$payloadRoot = Join-Path $PWD "publish/windows-$arch"
|
||||
if (-not (Test-Path $payloadRoot)) {
|
||||
Write-Error "Payload root not found: $payloadRoot"
|
||||
exit 1
|
||||
@@ -308,7 +374,7 @@ jobs:
|
||||
|
||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
if ($relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -418,6 +484,7 @@ jobs:
|
||||
-p:SelfContained=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:SkipAirAppHostBuild=true \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
@@ -425,12 +492,32 @@ 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"
|
||||
@@ -440,8 +527,13 @@ 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"
|
||||
rm -rf "$launcherDir" "$runtimeDir"
|
||||
|
||||
- name: Package as DEB
|
||||
run: |
|
||||
@@ -600,12 +692,13 @@ jobs:
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||
-c Release \
|
||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||
--self-contained \
|
||||
--self-contained:false \
|
||||
-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 }} \
|
||||
@@ -613,6 +706,36 @@ 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"
|
||||
@@ -635,6 +758,7 @@ 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"
|
||||
@@ -647,6 +771,11 @@ 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
4
.gitignore
vendored
@@ -6,6 +6,9 @@
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# Local NuGet global packages (NuGet.Config globalPackagesFolder)
|
||||
.nuget/packages/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
@@ -515,3 +518,4 @@ nul
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
/test-aot-publish
|
||||
/.claude/worktrees
|
||||
|
||||
106
.trae/documents/avalonia12-migration-plan.md
Normal file
106
.trae/documents/avalonia12-migration-plan.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Avalonia 12 迁移计划
|
||||
|
||||
## 当前状态
|
||||
|
||||
项目已完成以下迁移准备:
|
||||
|
||||
* `Directory.Packages.props` 中 Avalonia 包已升级到 `12.0.1`
|
||||
|
||||
* `FluentAvaloniaUI` 已升级到 `3.0.0-preview1`
|
||||
|
||||
* `Avalonia.Diagnostics` 已替换为 `AvaloniaUI.DiagnosticsSupport`
|
||||
|
||||
* `Avalonia.Controls.WebView` 已升级到 `12.0.0`
|
||||
|
||||
* `ClassIsland.Markdown.Avalonia` 已升级到 `12.0.0`
|
||||
|
||||
## 构建错误清单(26 errors)
|
||||
|
||||
### 1. 窗口装饰 API 移除(8 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/SettingsWindow.axaml.cs`(4 errors)
|
||||
|
||||
* `ExtendClientAreaChromeHints` 不存在(line 166, 179)
|
||||
|
||||
* `SystemDecorations` 已过时,需改用 `WindowDecorations`(line 168, 177)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs`(4 errors)
|
||||
|
||||
* `ExtendClientAreaChromeHints` 不存在(line 63, 72)
|
||||
|
||||
* `SystemDecorations` 已过时,需改用 `WindowDecorations`(line 65, 70)
|
||||
|
||||
**AXAML 文件**:13 个文件使用 `SystemDecorations` 属性(编译警告)
|
||||
|
||||
### 2. 变量/字段未找到(8 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
|
||||
|
||||
* `centerLeft` 不存在(line 759, 766, 778)
|
||||
|
||||
* `positions` 不存在(line 1266)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/MainWindow.DesktopPaging.cs`
|
||||
|
||||
* `child` 不存在(line 312)
|
||||
|
||||
* `_isThreeFingerOrRightDragSwipeActive` 不存在(line 517, 828, 847, 850)
|
||||
|
||||
### 3. API 变更(3 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/App.axaml.cs`
|
||||
|
||||
* `BindingPlugins` 不可访问(line 532, 537)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs`
|
||||
|
||||
* `IClipboard.SetTextAsync` 不存在(line 187)
|
||||
|
||||
**文件**:`LanMountainDesktop/Services/MonetColorService.cs`
|
||||
|
||||
* `Bitmap.CopyPixels` 参数不匹配(line 91)
|
||||
|
||||
### 4. 第三方库变更(1 error)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/SettingsWindow.axaml.cs`
|
||||
|
||||
* `FluentIcons.Avalonia.SymbolIconSource` 不存在(line 215)
|
||||
|
||||
### 5. 过时属性警告(需同步修复)
|
||||
|
||||
* `TextBox.Watermark` → `PlaceholderText`(7 处 .cs + 7 处 .axaml)
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### Phase 1: 修复窗口装饰 API(高优先级)
|
||||
|
||||
1. 重写 `SettingsWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
|
||||
2. 重写 `ComponentEditorWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
|
||||
3. 批量替换所有 `.axaml` 中的 `SystemDecorations` → `WindowDecorations`
|
||||
|
||||
### Phase 2: 修复 MainWindow 编译错误(高优先级)
|
||||
|
||||
1. 检查 `MainWindow.ComponentSystem.cs` 中 `centerLeft` 和 `positions` 的作用域问题
|
||||
2. 检查 `MainWindow.DesktopPaging.cs` 中 `child` 和 `_isThreeFingerOrRightDragSwipeActive` 的作用域问题
|
||||
3. 确认这些变量是否被意外删除或重命名
|
||||
|
||||
### Phase 3: 修复 Avalonia 12 API 变更(中优先级)
|
||||
|
||||
1. `App.axaml.cs`: 替换 `BindingPlugins.DataValidators` 的访问方式
|
||||
2. `DesktopComponentFailureView.cs`: 使用新的剪贴板 API
|
||||
3. `MonetColorService.cs`: 更新 `Bitmap.CopyPixels` 调用签名
|
||||
|
||||
### Phase 4: 修复第三方库变更(中优先级)
|
||||
|
||||
1. `SettingsWindow.axaml.cs`: 替换 `FluentIcons.Avalonia.SymbolIconSource` 为 v3 等效 API
|
||||
|
||||
### Phase 5: 清理过时属性(低优先级)
|
||||
|
||||
1. 批量替换 `Watermark` → `PlaceholderText`(所有 .cs 和 .axaml)
|
||||
|
||||
## 验证步骤
|
||||
|
||||
* 每阶段修复后运行 `dotnet build LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
* 最终运行 `dotnet test LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
403
.trae/documents/class-schedule-widget-redesign.md
Normal file
403
.trae/documents/class-schedule-widget-redesign.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# 课程表组件视觉重构 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 彻底重构阑山桌面的课程表(ClassScheduleWidget)组件视觉设计,参考小爱课程表的桌面小部件风格,实现时间轴+色块卡片布局、科目自动配色、当前课程进度高亮等现代化视觉效果。
|
||||
|
||||
**Architecture:** 保留现有数据层(ClassIslandScheduleDataService、Models)和组件注册机制不变,仅重构 Widget 的 UI 渲染层(XAML + code-behind 中的渲染逻辑)。新增科目配色服务,为每门课程分配稳定的区分色。先创建 HTML Mock 验证视觉效果,再移植到 Avalonia XAML。
|
||||
|
||||
**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览)
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有组件结构
|
||||
- **XAML**: `ClassScheduleWidget.axaml` — 仅定义了 RootBorder、HeaderGrid(日期+星期+课数)、ScrollViewer+CourseListPanel、StatusTextBlock
|
||||
- **Code-behind**: `ClassScheduleWidget.axaml.cs` — 所有课程项 UI 在 `CreateSingleItemControl()` 中手动构建:圆点(Bullet) + 文字栈(课程名/时间/详情)
|
||||
- **数据层**: `ClassIslandScheduleDataService` + `ClassIslandScheduleModels` — 不变
|
||||
- **编辑器**: `ClassScheduleComponentEditor.axaml(.cs)` — 不变
|
||||
|
||||
### 现有设计问题
|
||||
1. **视觉单调**: 仅用小圆点区分课程,所有课程外观一致,缺乏层次感
|
||||
2. **信息密度低**: 课程名、时间、教师名挤在一行,可读性差
|
||||
3. **当前课不突出**: 仅通过圆点颜色变化标识当前课程,几乎无法一眼识别
|
||||
4. **色彩硬编码**: 颜色值直接写在 C# 中,不使用语义资源键,不遵循 VISUAL_SPEC
|
||||
5. **无时间轴感**: 列表式排列无法体现课程的时间先后和持续长度
|
||||
|
||||
### 小爱课程表参考设计特征
|
||||
1. **时间轴布局**: 左侧显示时间刻度,右侧是课程色块卡片
|
||||
2. **科目配色**: 每门课程自动分配一种柔和区分色,卡片使用对应色块背景
|
||||
3. **当前课高亮**: 正在进行的课程有明显的视觉强调(放大/进度条/发光)
|
||||
4. **进度指示**: 当前课程显示上课进度(已过时间/总时长)
|
||||
5. **紧凑信息**: 课程名+教室/教师信息在色块内清晰排列
|
||||
6. **课间分隔**: 课间休息区域有视觉分隔(虚线/淡色区域)
|
||||
|
||||
---
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 视觉论文 (Visual Thesis)
|
||||
时间轴驱动的色块卡片布局,柔和科目配色,当前课程进度高亮——在桌面小组件有限空间内实现信息密度与美感的平衡。
|
||||
|
||||
### 布局结构
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 7/24 周一 今天3节课 │ ← 头部:日期 + 星期 + 课数
|
||||
├─────────────────────────────────────┤
|
||||
│ 08:00 ┌──────────────────────┐ │
|
||||
│ │ 语文 │ │ ← 科目色块卡片
|
||||
│ │ 王老师 · 教室301 │ │
|
||||
│ 08:45 └──────────────────────┘ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ 数学 ████████░░ 75% │ │ ← 当前课:进度条 + 高亮
|
||||
│ │ 李老师 · 教室205 │ │
|
||||
│ 09:30 └──────────────────────┘ │
|
||||
│ ... │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 科目配色方案
|
||||
使用一组预定义的柔和色彩,按科目名哈希值稳定分配:
|
||||
- 语文: #5B8FF9 (蓝)
|
||||
- 数学: #F6903D (橙)
|
||||
- 英语: #5AD8A6 (绿)
|
||||
- 物理: #E8684A (红)
|
||||
- 化学: #9270CA (紫)
|
||||
- 生物: #FF9845 (琥珀)
|
||||
- 历史: #1E9493 (青)
|
||||
- 地理: #FF99C3 (粉)
|
||||
- 政治: #7262FD (靛)
|
||||
- 体育: #78D3F8 (天蓝)
|
||||
- 默认: #8B95A5 (灰)
|
||||
|
||||
### 当前课程高亮
|
||||
- 卡片左侧显示 3px 宽的强调色竖条
|
||||
- 卡片底部显示细进度条(已过时间/总时长)
|
||||
- 卡片背景使用科目色的 15% 透明度版本
|
||||
- 非当前课程使用科目色的 8% 透明度版本
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml` | 修改 | 重构 XAML 布局:时间轴+卡片区域 |
|
||||
| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs` | 修改 | 重构渲染逻辑:色块卡片、科目配色、进度条 |
|
||||
| `LanMountainDesktop/Views/Components/SubjectColorService.cs` | 新建 | 科目配色服务:稳定哈希分配颜色 |
|
||||
| `mocks/class-schedule-mock.html` | 新建 | HTML Mock 预览(亮色+暗色) |
|
||||
|
||||
---
|
||||
|
||||
## Task 分解
|
||||
|
||||
### Task 1: 创建 HTML Mock 预览
|
||||
|
||||
**Files:**
|
||||
- Create: `mocks/class-schedule-mock.html`
|
||||
|
||||
- [ ] **Step 1: 创建 HTML Mock 文件**
|
||||
|
||||
创建完整的 HTML Mock,包含:
|
||||
- 亮色/暗色主题切换
|
||||
- 时间轴+色块卡片布局
|
||||
- 科目自动配色
|
||||
- 当前课程进度条高亮
|
||||
- 课间分隔区域
|
||||
- 响应式尺寸(模拟桌面组件 2x4 / 4x4 等尺寸)
|
||||
|
||||
Mock 中应包含示例数据:
|
||||
```
|
||||
08:00-08:45 语文 王老师
|
||||
08:55-09:40 数学 李老师 (当前课,进度 60%)
|
||||
09:50-10:35 英语 张老师
|
||||
10:45-11:30 物理 赵老师
|
||||
14:00-14:45 化学 陈老师
|
||||
14:55-15:40 生物 刘老师
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在浏览器中打开 Mock 验证效果**
|
||||
|
||||
Run: `start mocks/class-schedule-mock.html`
|
||||
|
||||
- [ ] **Step 3: 根据视觉效果调整 Mock 细节**
|
||||
|
||||
调整间距、色值、字体大小、进度条样式等直到满意。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建科目配色服务
|
||||
|
||||
**Files:**
|
||||
- Create: `LanMountainDesktop/Views/Components/SubjectColorService.cs`
|
||||
|
||||
- [ ] **Step 1: 实现 SubjectColorService**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal static class SubjectColorService
|
||||
{
|
||||
private static readonly (string Name, string Hex)[] Palette = [
|
||||
("语文", "#5B8FF9"),
|
||||
("数学", "#F6903D"),
|
||||
("英语", "#5AD8A6"),
|
||||
("物理", "#E8684A"),
|
||||
("化学", "#9270CA"),
|
||||
("生物", "#FF9845"),
|
||||
("历史", "#1E9493"),
|
||||
("地理", "#FF99C3"),
|
||||
("政治", "#7262FD"),
|
||||
("体育", "#78D3F8"),
|
||||
("音乐", "#F25E7E"),
|
||||
("美术", "#C2A1FD"),
|
||||
];
|
||||
|
||||
private static readonly string DefaultHex = "#8B95A5";
|
||||
|
||||
public static Color ResolveColor(string subjectName)
|
||||
{
|
||||
foreach (var (name, hex) in Palette)
|
||||
{
|
||||
if (subjectName.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Color.Parse(hex);
|
||||
}
|
||||
}
|
||||
|
||||
var hash = StableHash(subjectName);
|
||||
var index = (int)(hash % (uint)Palette.Length);
|
||||
return Color.Parse(Palette[index].Hex);
|
||||
}
|
||||
|
||||
public static Color ResolveBackgroundColor(string subjectName, bool isCurrent, bool isNight)
|
||||
{
|
||||
var baseColor = ResolveColor(subjectName);
|
||||
var alpha = isCurrent ? 0.18 : 0.08;
|
||||
return new Color(
|
||||
(byte)(alpha * 255),
|
||||
baseColor.R,
|
||||
baseColor.G,
|
||||
baseColor.B);
|
||||
}
|
||||
|
||||
public static Color ResolveForegroundColor(string subjectName, bool isNight)
|
||||
{
|
||||
var baseColor = ResolveColor(subjectName);
|
||||
return isNight
|
||||
? new Color(0xFF, (byte)Math.Min(255, baseColor.R + 60), (byte)Math.Min(255, baseColor.G + 60), (byte)Math.Min(255, baseColor.B + 60))
|
||||
: baseColor;
|
||||
}
|
||||
|
||||
private static uint StableHash(string input)
|
||||
{
|
||||
uint hash = 5381;
|
||||
foreach (var c in input)
|
||||
{
|
||||
hash = ((hash << 5) + hash) ^ (uint)c;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译通过**
|
||||
|
||||
Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug --no-restore`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 重构 ClassScheduleWidget XAML 布局
|
||||
|
||||
**Files:**
|
||||
- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml`
|
||||
|
||||
- [ ] **Step 1: 重写 XAML 布局**
|
||||
|
||||
新的 XAML 结构:
|
||||
- RootBorder 保持 `DesignCornerRadiusComponent`
|
||||
- 头部区域:日期(大号)+ 星期 + 课数 + 进度摘要
|
||||
- 课程列表区域:ScrollViewer 包裹 StackPanel
|
||||
- 每个 CourseItem 将在 code-behind 中构建为:Grid(时间列 + 卡片列)
|
||||
- 时间列:StartTime / EndTime 垂直排列
|
||||
- 卡片列:Border(科目色背景) > StackPanel(课程名 + 教师信息 + 进度条)
|
||||
|
||||
XAML 只定义骨架,课程项仍由 code-behind 动态构建(因为需要科目配色和进度计算)。
|
||||
|
||||
```xml
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="0">
|
||||
<Grid x:Name="LayoutGrid"
|
||||
RowDefinitions="Auto,*">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
Padding="16,12,16,8">
|
||||
<StackPanel x:Name="DateGroup"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="MonthTextBlock"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="SlashTextBlock"
|
||||
Text="/"
|
||||
FontWeight="Bold" />
|
||||
<TextBlock x:Name="DayTextBlock"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="WeekdayTextBlock"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border x:Name="ClassCountBadge"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
Padding="8,3"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMicro}">
|
||||
<TextBlock x:Name="ClassCountTextBlock"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<ScrollViewer x:Name="ContentScrollViewer"
|
||||
Grid.Row="1"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="CourseListPanel"
|
||||
Spacing="4" />
|
||||
</ScrollViewer>
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
IsVisible="False"
|
||||
TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 重构 ClassScheduleWidget 渲染逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs`
|
||||
|
||||
- [ ] **Step 1: 扩展 CourseItemViewModel**
|
||||
|
||||
在现有 record 中增加字段:
|
||||
|
||||
```csharp
|
||||
private sealed record CourseItemViewModel(
|
||||
string Name,
|
||||
string TimeRange,
|
||||
string Detail,
|
||||
bool IsCurrent,
|
||||
TimeSpan StartTime,
|
||||
TimeSpan EndTime,
|
||||
double Progress);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 BuildCourseItemViewModels 计算进度**
|
||||
|
||||
在构建 ViewModel 时,对当前课程计算 Progress = (now - startTime) / (endTime - startTime)。
|
||||
|
||||
- [ ] **Step 3: 重写 CreateSingleItemControl**
|
||||
|
||||
新的课程项 UI 结构:
|
||||
|
||||
```
|
||||
Grid (2列: 时间列 Auto + 卡片列 *)
|
||||
├── StackPanel (时间列)
|
||||
│ ├── TextBlock (开始时间, 如 "08:00")
|
||||
│ └── TextBlock (结束时间, 如 "08:45", 较淡)
|
||||
└── Border (卡片列, 科目色背景, 圆角 DesignCornerRadiusSm)
|
||||
├── 左侧强调竖条 (当前课显示, 3px宽, 科目色)
|
||||
└── StackPanel
|
||||
├── TextBlock (课程名, 科目色前景, 加粗)
|
||||
├── TextBlock (教师/教室, 次要色)
|
||||
└── ProgressBar (当前课显示, 科目色)
|
||||
```
|
||||
|
||||
关键改动点:
|
||||
1. 移除圆点(Bullet),改用时间轴左侧时间标签
|
||||
2. 课程卡片使用 `SubjectColorService` 配色
|
||||
3. 当前课程卡片左侧显示强调竖条 + 底部进度条
|
||||
4. 课间区域用淡色分隔线标识
|
||||
5. 颜色使用语义资源键(`AdaptiveTextPrimaryBrush` 等),科目色通过 `SubjectColorService` 获取
|
||||
|
||||
- [ ] **Step 4: 重写 ApplyAdaptiveLayout**
|
||||
|
||||
更新自适应布局逻辑:
|
||||
- 头部日期/星期/课数徽章的字号和间距
|
||||
- 移除旧的圆点、文字栈相关计算
|
||||
- 新增时间列宽度、卡片圆角、进度条高度等计算
|
||||
- 使用 `ComponentChromeCornerRadiusHelper` 获取圆角 Token
|
||||
|
||||
- [ ] **Step 5: 更新 IncrementalUpdateItems 和 IncrementalUpdateCurrentCourseHighlight**
|
||||
|
||||
适配新的 UI 结构:
|
||||
- 更新进度条值
|
||||
- 更新科目色背景
|
||||
- 更新强调竖条可见性
|
||||
|
||||
- [ ] **Step 6: 更新 RefreshSchedule 中的时间计算**
|
||||
|
||||
在 `BuildCourseItemViewModels` 中传入 `StartTime`/`EndTime`/`Progress`。
|
||||
|
||||
- [ ] **Step 7: 验证编译通过**
|
||||
|
||||
Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证与测试
|
||||
|
||||
- [ ] **Step 1: 运行项目查看效果**
|
||||
|
||||
Run: `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
|
||||
- [ ] **Step 2: 运行相关测试**
|
||||
|
||||
Run: `dotnet test LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
- [ ] **Step 3: 检查圆角规范合规**
|
||||
|
||||
确认 RootBorder 使用 `DesignCornerRadiusComponent`,内部卡片使用 `DesignCornerRadiusSm`/`DesignCornerRadiusMd`,无硬编码圆角值。
|
||||
|
||||
---
|
||||
|
||||
## 假设与决策
|
||||
|
||||
1. **科目配色**: 使用预定义调色板 + 哈希回退,不依赖 ClassIsland 数据中的科目颜色(因为 ClassIsland 不提供科目颜色字段)
|
||||
2. **进度条**: 仅当前课程显示进度条,非当前课程不显示
|
||||
3. **课间分隔**: 用 4px 间距 + 可选的淡色虚线分隔,不做复杂的课间休息区域
|
||||
4. **Mock 优先**: 先完成 HTML Mock 确认视觉效果,再实现 Avalonia 代码
|
||||
5. **编辑器不变**: ClassScheduleComponentEditor 不需要修改
|
||||
6. **数据层不变**: ClassIslandScheduleDataService 和 Models 不需要修改
|
||||
7. **接口兼容**: IDesktopComponentWidget、ITimeZoneAwareComponentWidget、IComponentPlacementContextAware 接口实现不变
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. HTML Mock 在浏览器中展示效果满意
|
||||
2. Avalonia 项目编译通过
|
||||
3. 运行项目,课程表组件显示新布局
|
||||
4. 亮色/暗色主题切换正常
|
||||
5. 当前课程高亮和进度条正常
|
||||
6. 科目配色稳定(同一科目每次显示颜色一致)
|
||||
7. 测试通过
|
||||
850
.trae/documents/launcher-resx-i18n-plan.md
Normal file
850
.trae/documents/launcher-resx-i18n-plan.md
Normal file
@@ -0,0 +1,850 @@
|
||||
# 启动器 RESX 多语言适配实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 LanMountainDesktop.Launcher 引入 RESX 资源文件,实现启动器 UI 的多语言适配,消除所有硬编码中英文字符串。
|
||||
|
||||
**Architecture:** 在 Launcher 项目中创建 RESX 资源文件体系(默认 zh-CN + en-US/ja-JP/ko-KR),通过 .NET 内置 `ResourceManager` 机制实现本地化。启动时从主应用 `settings.json` 读取 `LanguageCode` 字段设置 `CultureInfo.CurrentUICulture`,AXAML 中使用 `x:Static` 引用资源,C# 代码中通过 `Strings.ResourceName` 强类型访问。
|
||||
|
||||
**Tech Stack:** .NET RESX 资源文件、Avalonia `x:Static` 标记扩展、`System.Globalization.CultureInfo`
|
||||
|
||||
---
|
||||
|
||||
## 现状分析
|
||||
|
||||
### 问题概述
|
||||
|
||||
1. **启动器完全没有本地化支持**:所有 UI 字符串硬编码,中英文混杂严重
|
||||
2. **纯英文窗口**:SplashWindow、ErrorWindow、MultiInstancePromptWindow、DataLocationPromptWindow、LoadingDetailsWindow
|
||||
3. **纯中文窗口**:OobeWindow、MigrationPromptWindow、UpdateWindow、ErrorDebugWindow、DevDebugWindow、PrivacyPolicyWindow
|
||||
4. **启动器不读取主应用语言设置**:没有 `LanguageCode` 相关代码
|
||||
5. **硬编码字符串总量约 180+ 条**,分布在 11 个 AXAML 视图和 11 个 C# code-behind 文件中
|
||||
|
||||
### 方案选择:RESX vs JSON
|
||||
|
||||
| 维度 | RESX(本方案) | JSON(主项目模式) |
|
||||
|------|---------------|-------------------|
|
||||
| 编译时安全 | ✅ 强类型 `Strings.KeyName` | ❌ 字符串键值 `L("key", "fallback")` |
|
||||
| AXAML 集成 | ✅ `x:Static` 直接引用 | ❌ 需 code-behind 赋值 |
|
||||
| 回退机制 | ✅ 内置(默认资源 → 特定文化) | ✅ 自定义 `fallback` 参数 |
|
||||
| 新增语言 | 需添加 RESX 文件并重新编译 | 仅添加 JSON 文件 |
|
||||
| AOT 兼容性 | ⚠️ 需额外配置 | ✅ 已验证 |
|
||||
| 与主项目一致性 | ❌ 不同模式 | ✅ 一致 |
|
||||
|
||||
**选择 RESX 的理由**:启动器是独立轻量进程,不需要运行时语言切换;强类型访问减少拼写错误;`x:Static` 比 code-behind 赋值更清晰;RESX 的内置回退机制足够满足启动器需求。
|
||||
|
||||
### AOT 兼容性说明
|
||||
|
||||
Launcher 项目支持 Native AOT 发布。RESX 的 `ResourceManager` 依赖反射,需要:
|
||||
1. 在 `.csproj` 中添加 `<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# 中或使用独立资源文件。
|
||||
@@ -154,7 +154,7 @@
|
||||
│ │
|
||||
│ 方案 2: 命名管道(推荐用于进度报告) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
|
||||
│ │ [历史方案] Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
|
||||
│ │ 主程序连接并发送进度消息 │ │
|
||||
│ │ │ │
|
||||
│ │ 消息格式: JSON │ │
|
||||
@@ -289,7 +289,7 @@ public static class LauncherIpcConstants
|
||||
|
||||
#### 4. 实现 IPC 服务端
|
||||
|
||||
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
|
||||
**历史方案,已废弃**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
|
||||
|
||||
```csharp
|
||||
using System.IO.Pipes;
|
||||
@@ -428,7 +428,7 @@ public async Task<LauncherResult> RunAsync()
|
||||
|
||||
#### 6. 实现 IPC 客户端
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
|
||||
**历史方案,已废弃**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
|
||||
|
||||
```csharp
|
||||
using System.IO.Pipes;
|
||||
@@ -672,8 +672,8 @@ public class UpdateInstallationService
|
||||
### 新增文件
|
||||
|
||||
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
|
||||
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
|
||||
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
|
||||
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - 历史启动进度 IPC 服务端,已由公共 IPC 通知替代
|
||||
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - 历史启动进度 IPC 客户端,已由公共 IPC 通知替代
|
||||
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
|
||||
|
||||
### 删除文件
|
||||
@@ -715,3 +715,11 @@ public class UpdateInstallationService
|
||||
- [ ] GitHub Actions 打包成功
|
||||
- [ ] 安装程序图标正常
|
||||
- [ ] 快捷方式图标正常
|
||||
|
||||
## 2026 Multi-instance Policy Update
|
||||
|
||||
- The old launcher progress pipe is historical only; current startup progress uses public IPC.
|
||||
- Launcher now reads Host `settings.json` for `MultiInstanceLaunchBehavior` before normal launch.
|
||||
- Existing Host behavior is policy-driven: restart app, open desktop silently, prompt only, or notify and open desktop.
|
||||
- Host no longer owns the single-instance listener or already-running prompt; repeated-launch policy lives in Launcher.
|
||||
- The repeated-launch prompt is a Fluent Launcher window; Host public IPC only exposes execution actions such as activate, restart, and exit.
|
||||
|
||||
212
.trae/documents/update-settings-redesign.md
Normal file
212
.trae/documents/update-settings-redesign.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# 更新设置界面重设计实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将更新设置页面从丑陋的卡片堆叠布局重设计为遵循 Fluent Design 的 FASettingsExpander 列表布局,与项目其他设置页面保持视觉一致性。
|
||||
|
||||
**Architecture:** 移除所有 `Border.settings-section-card` 包裹,改用 `FASettingsExpander` + `IconText` 分节标题 + `Separator` 分隔线的统一模式。操作按钮改为仅显示当前可用操作。版本信息改为 `FASettingsExpanderItem` 行项目展示。ViewModel 层新增 `ActiveActions` 计算属性来驱动按钮可见性。
|
||||
|
||||
**Tech Stack:** Avalonia UI 11, FluentAvalonia 2.x, CommunityToolkit.Mvvm
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` | 更新页面 AXAML 布局 |
|
||||
| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs` | 代码隐藏 |
|
||||
| `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` | 视图模型 |
|
||||
| `LanMountainDesktop/Styles/SettingsCardStyles.axaml` | 通用设置样式 |
|
||||
| `LanMountainDesktop/Controls/IconText.axaml(.cs)` | 分节标题控件 |
|
||||
| `LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs` | UpdatePhase 枚举和扩展方法 |
|
||||
|
||||
### 核心问题
|
||||
|
||||
1. **4 个 `Border.settings-section-card` 卡片**:状态卡、版本信息卡、进度卡、操作卡,每个都带边框+阴影+圆角,视觉零碎
|
||||
2. **FAInfoBar 嵌套在卡片内**:冗余的容器层级
|
||||
3. **7 个按钮 3×3 网格**:大量按钮在当前阶段不可用但仍然占据空间
|
||||
4. **与其他设置页面风格不一致**:GeneralSettingsPage、AppearanceSettingsPage 等全部使用 `FASettingsExpander` 列表
|
||||
|
||||
### 参考基准
|
||||
|
||||
- [GeneralSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml):`IconText` 分节标题 → `FASettingsExpander` 列表 → `Separator` 分隔
|
||||
- [AppearanceSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml):同上模式
|
||||
- [AboutSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml):`FAInfoBar` 用于静态信息展示
|
||||
- Windows 11 设置 > Windows Update:顶部状态区 + 进度条 + 操作按钮,下方展开区展示详情
|
||||
|
||||
---
|
||||
|
||||
## 设计决策
|
||||
|
||||
| 决策项 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 布局模式 | FASettingsExpander 列表 | 与其他设置页面统一,Fluent Design 原生控件 |
|
||||
| 按钮策略 | 仅显示可用操作 | 简洁、不混乱,Windows 11 更新页面也是此模式 |
|
||||
| 版本信息 | FASettingsExpanderItem 行项目 | 每行一个信息,干净可扫描 |
|
||||
| 进度展示 | 内嵌在状态 Expander 内 | 进度是状态的一部分,不应独立成卡 |
|
||||
| 偏好设置 | 保留 FASettingsExpander | 已经是正确模式,微调即可 |
|
||||
|
||||
---
|
||||
|
||||
## 新布局结构
|
||||
|
||||
```
|
||||
ScrollViewer
|
||||
└── StackPanel (settings-page-container settings-page-animated)
|
||||
├── TextBlock (settings-section-title: "更新")
|
||||
├── TextBlock (settings-section-description: 描述文字)
|
||||
│
|
||||
├── IconText (Icon="ArrowSync", Text="更新状态")
|
||||
│
|
||||
├── FASettingsExpander "检查更新" (IsClickEnabled=True, Command=CheckCommand)
|
||||
│ ├── IconSource: ArrowSync 图标
|
||||
│ └── Footer: Button "检查更新" (仅 CanCheck 时可见)
|
||||
│
|
||||
├── FASettingsExpander "更新进度" (IsVisible=IsBusy||IsProgressVisible||IsPaused)
|
||||
│ ├── IconSource: FAProgressRing / 对应阶段图标
|
||||
│ ├── Footer: PhaseText + ProgressFraction
|
||||
│ └── FASettingsExpanderItem
|
||||
│ ├── ProgressBar (ProgressFraction)
|
||||
│ ├── ProgressDetail 文字
|
||||
│ └── 操作按钮行 (仅可用操作)
|
||||
│ ├── Button "下载" (CanDownload)
|
||||
│ ├── Button "安装" (CanInstall)
|
||||
│ ├── Button "暂停" (CanPause)
|
||||
│ ├── Button "继续" (CanResume)
|
||||
│ ├── Button "回滚" (CanRollback)
|
||||
│ └── Button "取消" (CanCancel)
|
||||
│
|
||||
├── FASettingsExpander "暂停" (IsVisible=IsPaused)
|
||||
│ └── FAInfoBar (PausedBadgeText + PausedHintText)
|
||||
│
|
||||
├── Separator (settings-separator)
|
||||
│
|
||||
├── IconText (Icon="Info", Text="版本信息")
|
||||
│
|
||||
├── FASettingsExpander "当前版本" (IsClickEnabled=False)
|
||||
│ ├── IconSource: 版本图标
|
||||
│ └── Footer: CurrentVersionText
|
||||
│
|
||||
├── FASettingsExpander "最新版本" (IsClickEnabled=False)
|
||||
│ ├── IconSource: 下载图标
|
||||
│ └── Footer: LatestVersionText (或 "已是最新")
|
||||
│
|
||||
├── FASettingsExpander "发布时间" (IsClickEnabled=False)
|
||||
│ ├── IconSource: 日历图标
|
||||
│ └── Footer: PublishedAtText
|
||||
│
|
||||
├── FASettingsExpander "上次检查" (IsClickEnabled=False)
|
||||
│ ├── IconSource: 时钟图标
|
||||
│ └── Footer: LastCheckedText
|
||||
│
|
||||
├── FASettingsExpander "更新类型" (IsClickEnabled=False)
|
||||
│ ├── IconSource: 标签图标
|
||||
│ └── Footer: UpdateTypeText
|
||||
│
|
||||
├── Separator (settings-separator)
|
||||
│
|
||||
├── IconText (Icon="Settings", Text="更新偏好")
|
||||
│
|
||||
└── FASettingsExpander "更新偏好" (IsExpanded=True)
|
||||
├── IconSource: 设置齿轮图标
|
||||
├── FASettingsExpanderItem "更新频道"
|
||||
│ └── Footer: ComboBox (stable/preview)
|
||||
├── FASettingsExpanderItem "下载源"
|
||||
│ └── Footer: ComboBox (plonds/github/proxy)
|
||||
├── FASettingsExpanderItem "更新模式"
|
||||
│ └── Footer: ComboBox (manual/confirm/silent)
|
||||
└── FASettingsExpanderItem "下载线程数"
|
||||
└── Footer: Slider + TextBlock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### Task 1: 重写 UpdateSettingsPage.axaml 布局
|
||||
|
||||
**Files:**
|
||||
- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml`
|
||||
|
||||
**What:** 完全重写 AXAML,将 4 个 `Border.settings-section-card` 替换为 `FASettingsExpander` 列表布局。
|
||||
|
||||
**Key changes:**
|
||||
1. 移除所有 `Border.settings-section-card` 包裹
|
||||
2. 使用 `controls:IconText` 做分节标题(与 GeneralSettingsPage 一致)
|
||||
3. 状态区域:`FASettingsExpander` + `IsClickEnabled=True` + `Command=CheckCommand`,Footer 放检查按钮
|
||||
4. 进度区域:`FASettingsExpander` 内嵌 ProgressBar + 操作按钮,仅 `IsBusy||IsProgressVisible||IsPaused` 时可见
|
||||
5. 版本信息:每个字段一个 `FASettingsExpander`,Footer 直接显示值(参考 Windows 11 更新页面的行项目模式)
|
||||
6. 偏好设置:保留 `FASettingsExpander` + `FASettingsExpanderItem` 模式,但将 TextBox 改为 ComboBox(更符合 Fluent 规范)
|
||||
7. 使用 `Separator classes="settings-separator"` 分隔三大区域
|
||||
|
||||
**Why:** 与项目其他设置页面统一风格,遵循 Fluent Design,消除卡片堆叠的视觉噪音。
|
||||
|
||||
**How:**
|
||||
- 参照 GeneralSettingsPage.axaml 的布局模式
|
||||
- 参照 AppearanceSettingsPage.axaml 的 FASettingsExpander 使用方式
|
||||
- 参照 AboutSettingsPage.axaml 的 FAInfoBar 使用方式
|
||||
|
||||
### Task 2: 更新 ViewModel — 添加 ComboBox 数据源和按钮可见性属性
|
||||
|
||||
**Files:**
|
||||
- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs`
|
||||
|
||||
**What:**
|
||||
1. 将更新频道、下载源、更新模式从 `TextBox` 绑定改为 `ComboBox` 绑定,添加 `ObservableCollection<SelectionOption>` 类型的数据源属性
|
||||
2. 添加 `IsProgressSectionVisible` 计算属性(`IsBusy || IsProgressVisible || IsPaused`)
|
||||
3. 添加 `IsUpdateAvailableSectionVisible` 计算属性(`IsUpdateAvailable`)
|
||||
4. 添加 `IsStatusInfoVisible` 计算属性(有 StatusMessage 且非空闲时)
|
||||
5. 移除不再需要的独立按钮文本属性(CheckButtonText 保留,其他按钮文本属性保留但仅在可见时使用)
|
||||
|
||||
**Why:** ComboBox 比 TextBox 更适合有限选项的输入,且与 GeneralSettingsPage 的模式一致。按钮可见性属性让 AXAML 可以用 `IsVisible` 绑定控制按钮显示。
|
||||
|
||||
**How:**
|
||||
- 参考 GeneralSettingsPageViewModel 中 SelectionOption 的使用方式
|
||||
- 在 `OnCurrentPhaseChanged` 中触发新属性的 OnPropertyChanged
|
||||
|
||||
### Task 3: 将偏好设置 TextBox 替换为 ComboBox
|
||||
|
||||
**Files:**
|
||||
- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` (在 Task 1 中一并完成)
|
||||
- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` (在 Task 2 中一并完成)
|
||||
|
||||
**What:** 将更新频道、下载源、更新模式三个 `TextBox` 替换为 `ComboBox`,使用 `SelectionOption` 数据模板。
|
||||
|
||||
**Why:** 有限选项应使用 ComboBox 而非自由文本输入,这是 Fluent Design 的基本规范,也与 GeneralSettingsPage 中的语言/时区选择一致。
|
||||
|
||||
### Task 4: 构建验证
|
||||
|
||||
**Files:**
|
||||
- 无新文件
|
||||
|
||||
**What:** 运行 `dotnet build` 确保编译通过,检查 AXAML 绑定是否正确。
|
||||
|
||||
---
|
||||
|
||||
## Assumptions & Decisions
|
||||
|
||||
1. **不修改 UpdateOrchestrator 和 UpdateState** — 只改 UI 层和 ViewModel 的展示逻辑,不改底层更新引擎
|
||||
2. **不修改 SettingsCardStyles.axaml** — 通用样式保持不变,移除的是 UpdateSettingsPage 对它的使用
|
||||
3. **保留所有 ViewModel 属性** — 即使某些属性在新布局中不再直接使用(如独立的 ActionsTitle),也保留以避免破坏本地化系统
|
||||
4. **ComboBox 选项硬编码在 ViewModel** — 参考 GeneralSettingsPageViewModel 的 SelectionOption 模式
|
||||
5. **进度区域在空闲时隐藏** — 不显示空的进度条,只在有活动时展示
|
||||
6. **FAInfoBar 仅用于暂停/错误提示** — 不再嵌套在卡片内,直接放在 FASettingsExpanderItem 内
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过
|
||||
2. 运行应用,导航到设置 > 更新页面,验证:
|
||||
- 页面布局与 GeneralSettingsPage 风格一致
|
||||
- 无圆角矩形卡片包裹
|
||||
- 检查更新按钮可用
|
||||
- 进度区域在空闲时隐藏
|
||||
- 版本信息以行项目形式展示
|
||||
- 偏好设置使用 ComboBox
|
||||
- 操作按钮仅显示当前可用的
|
||||
3. 点击「检查更新」,验证状态变化和进度展示
|
||||
4. 验证偏好设置的 ComboBox 选择能正确保存和加载
|
||||
559
.trae/documents/weather-widget-material-redesign.md
Normal file
559
.trae/documents/weather-widget-material-redesign.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# 天气组件 Material Design 重设计计划
|
||||
|
||||
> **目标:** 全面重构阑山桌面天气组件的视觉设计,遵循 Material Design 3 规范,参考 Google Weather、几何天气、Breez 天气和柠檬天气的设计语言。
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有组件
|
||||
1. **WeatherWidget** - 基础天气(温度+天气状况+位置)
|
||||
2. **ExtendedWeatherWidget** - 扩展天气(含指标、逐小时、逐日预报)
|
||||
3. **HourlyWeatherWidget** - 逐小时天气
|
||||
4. **MultiDayWeatherWidget** - 多日天气
|
||||
5. **WeatherClockWidget** - 天气时钟
|
||||
|
||||
### 现有问题
|
||||
- 排版层次不清晰,文字大小对比不够
|
||||
- 布局过于紧凑,缺乏呼吸感
|
||||
- 内部卡片使用简单纯色背景,缺乏 Material 风格
|
||||
- 背景场景和前景内容缺乏深度分离
|
||||
- 圆角和间距不统一
|
||||
|
||||
### 现有视觉系统
|
||||
- 4套调色板:Google(默认)、Geometric、Breezy、LemonFlutter
|
||||
- 动态背景场景:MaterialWeatherSceneControl 绘制渐变+装饰
|
||||
- 图标系统:WeatherIconView + WeatherIconAssetResolver
|
||||
|
||||
---
|
||||
|
||||
## 设计方向
|
||||
|
||||
### 核心原则
|
||||
1. **Material Design 3** - 使用 M3 的排版、颜色、间距和形状规范
|
||||
2. **信息层级清晰** - 大字体温度、次要信息弱化
|
||||
3. **呼吸感** - 合理的间距和留白
|
||||
4. **深度感** - 前景卡片与背景场景分离
|
||||
5. **圆角一致性** - 遵循 DesignCornerRadius 规范
|
||||
|
||||
### 参考风格
|
||||
- **Google Weather** - 大字体温度、清晰层级、圆角卡片、柔和渐变
|
||||
- **几何天气** - 几何装饰、现代感
|
||||
- **Breez** - 清新留白、柔和色彩
|
||||
- **柠檬天气** - 活泼明亮
|
||||
|
||||
---
|
||||
|
||||
## 具体改动计划
|
||||
|
||||
### Task 1: 优化 MaterialWeatherPalette 和调色板系统
|
||||
|
||||
**文件:** `LanMountainDesktop/Views/Components/MaterialWeatherVisualTheme.cs`
|
||||
|
||||
**改动:**
|
||||
- 调整所有调色板的对比度,确保文字可读性
|
||||
- 优化背景渐变色彩,更加柔和自然
|
||||
- 统一文字主色和次色的对比度比例
|
||||
- 为每个风格增加 `SurfaceColor` 和 `SurfaceVariantColor` 用于卡片背景
|
||||
|
||||
**当前调色板字段:**
|
||||
```csharp
|
||||
public sealed record MaterialWeatherPalette(
|
||||
Color BackgroundTop,
|
||||
Color BackgroundBottom,
|
||||
Color PrimaryShape,
|
||||
Color SecondaryShape,
|
||||
Color AccentShape,
|
||||
Color TextPrimary,
|
||||
Color TextSecondary,
|
||||
Color SurfaceTint,
|
||||
Color OverlayTint);
|
||||
```
|
||||
|
||||
**新增字段:**
|
||||
```csharp
|
||||
Color SurfaceColor, // 卡片表面色(低透明度白色/黑色)
|
||||
Color SurfaceVariantColor, // 变体表面色
|
||||
Color OutlineColor // 分割线/边框色
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 重构 WeatherWidget(基础天气组件)
|
||||
|
||||
**文件:**
|
||||
- `LanMountainDesktop/Views/Components/WeatherWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/WeatherWidget.axaml.cs`
|
||||
|
||||
**设计目标:**
|
||||
- 大字体温度显示(类似 Google Weather)
|
||||
- 天气状况文字清晰可读
|
||||
- 位置和温度范围弱化显示
|
||||
- 图标与文字对齐优化
|
||||
|
||||
**XAML 改动:**
|
||||
```xml
|
||||
<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)
|
||||
342
.trae/documents/weather-widget-visual-redesign.md
Normal file
342
.trae/documents/weather-widget-visual-redesign.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# 天气组件视觉重构 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 彻底重构阑山桌面天气系列组件的背景视觉和文字排版,为每种图标风格(Google Weather / Geometric / Breezy / Lemon)提供独立的背景配色和视觉质感,参考各天气 App 的 Material Design 风格,实现几何质感+柔和渐变+层次分明的排版。
|
||||
|
||||
**Architecture:** 保留现有数据层(WeatherWidgetBase、WeatherSnapshot、WeatherIconAssetResolver)和组件注册机制不变。核心改动:1) 将 `MaterialWeatherVisualTheme.ResolvePalette()` 扩展为按 styleId 分派不同配色方案;2) 重构 `MaterialWeatherSceneControl` 为按 styleId 渲染不同背景风格;3) 改进各天气 Widget 的文字排版层次。先创建 HTML Mock 验证视觉效果。
|
||||
|
||||
**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览)
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有天气组件体系
|
||||
5 个天气组件,全部继承自 `WeatherWidgetBase`:
|
||||
|
||||
| 组件 | 文件 | 功能 |
|
||||
|------|------|------|
|
||||
| WeatherWidget | `WeatherWidget.axaml(.cs)` | 基础天气:温度+状况+图标+位置 |
|
||||
| WeatherClockWidget | `WeatherClockWidget.axaml(.cs)` | 天气+时钟 |
|
||||
| ExtendedWeatherWidget | `ExtendedWeatherWidget.axaml(.cs)` | 扩展天气:含指标/小时/多日预报 |
|
||||
| HourlyWeatherWidget | `HourlyWeatherWidget.axaml(.cs)` | 逐小时天气 |
|
||||
| MultiDayWeatherWidget | `MultiDayWeatherWidget.axaml(.cs)` | 多日天气 |
|
||||
|
||||
### 核心问题
|
||||
|
||||
1. **背景与图标风格脱钩**: `MaterialWeatherVisualTheme.ResolvePalette()` 只返回一套配色,与 `WeatherVisualStyleId`(GoogleWeatherV4/Geometric/Breezy/LemonFlutter)完全无关。切换图标风格时背景不变。
|
||||
2. **背景视觉单调**: `MaterialWeatherSceneControl` 只有一种手绘几何风格(椭圆+云+雨滴),质感差,缺乏各 App 的特色。
|
||||
3. **文字排版粗糙**: 温度数字不够大,信息层次不分明,指标用纯文字堆叠,预报区域无卡片样式。
|
||||
4. **半透明遮罩硬编码**: 所有组件都覆盖 `<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. 测试通过
|
||||
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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.
|
||||
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
7
.trae/specs/air-app-whiteboard/checklist.md
Normal file
7
.trae/specs/air-app-whiteboard/checklist.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Checklist
|
||||
|
||||
- [x] Main app builds in Debug.
|
||||
- [x] AirAppHost builds in Debug.
|
||||
- [x] Tests project builds in Debug.
|
||||
- [x] `AirAppLauncherServiceTests` pass.
|
||||
- [ ] Manual UI verification on a running desktop session.
|
||||
26
.trae/specs/air-app-whiteboard/spec.md
Normal file
26
.trae/specs/air-app-whiteboard/spec.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Air APP Whiteboard
|
||||
|
||||
## Goal
|
||||
|
||||
Allow the built-in whiteboard desktop components to open a full-screen Air APP that runs in `LanMountainDesktop.AirAppHost` and reuses the same persisted whiteboard note as the source component instance.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a toolbar surface-mode button to `WhiteboardWidget`.
|
||||
- In component mode, the button opens the `whiteboard` Air APP through `IAirAppLauncherService`.
|
||||
- In Air APP mode, the same button saves the current note and closes the Air APP window.
|
||||
- `DesktopWhiteboard` and `DesktopBlackboardLandscape` share the same mechanism and keep using their component id plus placement id as the note identity.
|
||||
- `LanMountainDesktop.AirAppHost` may reference the host assembly to reuse built-in UI controls, but the host app must not reference AirAppHost as a normal assembly dependency.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Third-party Air APP SDK declarations.
|
||||
- Whiteboard feature rewrites or alternate whiteboard persistence.
|
||||
- Taskbar minimization behavior; v1 closes the Air APP window when the user exits from the bottom toolbar.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Building the main app also builds and copies `LanMountainDesktop.AirAppHost` output.
|
||||
- Clicking the whiteboard toolbar full-screen button launches a separate AirAppHost process.
|
||||
- Repeated opens of the same whiteboard component instance activate the existing process instead of spawning duplicates.
|
||||
- Closing and reopening the Air APP keeps the same whiteboard contents.
|
||||
8
.trae/specs/air-app-whiteboard/tasks.md
Normal file
8
.trae/specs/air-app-whiteboard/tasks.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Add `whiteboard` launch support to `AirAppLauncherService`.
|
||||
- [x] Add whiteboard single-instance keys based on component id and placement id.
|
||||
- [x] Add component/Air APP surface modes to `WhiteboardWidget`.
|
||||
- [x] Render `WhiteboardWidget` full screen from `LanMountainDesktop.AirAppHost`.
|
||||
- [x] Keep AirAppHost build/copy output available from the main app build.
|
||||
- [x] Add launcher argument and instance-key tests.
|
||||
8
.trae/specs/air-app-window-chrome/checklist.md
Normal file
8
.trae/specs/air-app-window-chrome/checklist.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Checklist
|
||||
|
||||
- [x] Descriptor supports Standard, Borderless, FullScreen, Tool, and BackgroundOnly modes.
|
||||
- [x] World Clock Air APP uses FluentAvalonia standard title-bar chrome.
|
||||
- [x] Whiteboard Air APP opens as a fullscreen titlebar-less window.
|
||||
- [x] Air APP windows do not use fused desktop bottom-most services.
|
||||
- [x] Air APP windows do not use `Topmost=true` promotion.
|
||||
- [ ] Manual verification for each chrome mode once non-built-in Air APP declarations are added.
|
||||
22
.trae/specs/air-app-window-chrome/spec.md
Normal file
22
.trae/specs/air-app-window-chrome/spec.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Air APP Window Chrome
|
||||
|
||||
## Goal
|
||||
|
||||
Give Air APPs explicit window chrome modes so title bars, fullscreen windows, borderless windows, tool windows, and future background-only apps are configured by the Air APP host instead of ad hoc component code.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Air APP host resolves an `AirAppWindowDescriptor` from launch options before creating content.
|
||||
- Supported chrome modes are `Standard`, `Borderless`, `FullScreen`, `Tool`, and `BackgroundOnly`.
|
||||
- `Standard` uses FluentAvalonia `FAAppWindow` title-bar chrome and normal app-window behavior.
|
||||
- `Borderless` removes title-bar chrome while keeping a normal app window surface.
|
||||
- `FullScreen` removes title-bar chrome and enters fullscreen.
|
||||
- `Tool` keeps FluentAvalonia title-bar chrome but disables resizing and hides the taskbar entry.
|
||||
- `BackgroundOnly` is reserved for a later background Air APP lifecycle and is not used by built-in v1 apps.
|
||||
- Built-in `world-clock` uses `Standard`; built-in `whiteboard` uses `FullScreen`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Third-party plugin Air APP declarations.
|
||||
- Replacing Launcher lifecycle IPC.
|
||||
- Moving title-bar rendering into desktop components.
|
||||
8
.trae/specs/air-app-window-chrome/tasks.md
Normal file
8
.trae/specs/air-app-window-chrome/tasks.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Add `AirAppWindowChromeMode` and `AirAppWindowDescriptor`.
|
||||
- [x] Map built-in `world-clock` to `Standard` chrome.
|
||||
- [x] Map built-in `whiteboard` to `FullScreen` chrome.
|
||||
- [x] Apply descriptor settings from `AirAppWindow`.
|
||||
- [x] Add regression tests for supported modes and built-in mode mapping.
|
||||
- [x] Replace the hand-rolled Air APP title bar with FluentAvalonia `FAAppWindow` chrome.
|
||||
21
.trae/specs/avalonia-12-migration/checklist.md
Normal file
21
.trae/specs/avalonia-12-migration/checklist.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `Directory.Packages.props` contains the Avalonia 12 dependency baseline.
|
||||
- [x] Main host references `Avalonia.Controls.WebView`.
|
||||
- [x] Source no longer references `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
|
||||
- [x] `BrowserWidget` uses `NativeWebView.Source`, `Navigate`, `Refresh()`, `NavigationStarted`, and `EnvironmentRequested`.
|
||||
- [x] WebView blanking navigates to `about:blank`.
|
||||
- [x] Plugin SDK package version is `5.0.0`.
|
||||
- [x] `PluginSdkInfo.ApiVersion` is `5.0.0`.
|
||||
- [x] Plugin template package version default is `5.0.0`.
|
||||
- [x] Plugin template manifest `apiVersion` is `5.0.0`.
|
||||
- [x] Launcher data location config resolution cannot recurse through `ResolveDataRoot()`.
|
||||
- [x] `OobeStateServiceTests` pass.
|
||||
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` has 0 errors.
|
||||
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` completes without a test host stack overflow.
|
||||
- [ ] Windows host smoke test completed.
|
||||
- [ ] Windows Launcher smoke test completed.
|
||||
- [ ] Settings window FluentAvalonia 3 smoke test completed.
|
||||
- [ ] Component editor Material smoke test completed.
|
||||
- [ ] BrowserWidget navigation/refresh/page activation smoke test completed.
|
||||
- [ ] WebView2 missing-runtime diagnostic smoke test completed.
|
||||
49
.trae/specs/avalonia-12-migration/spec.md
Normal file
49
.trae/specs/avalonia-12-migration/spec.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Avalonia 12 Full Stack Migration
|
||||
|
||||
## Summary
|
||||
|
||||
LanMountainDesktop has moved its desktop stack to the Avalonia 12 baseline. The migration covers the main host, Launcher, Plugin SDK, plugin runtime loading policy, official WebView usage, ClassIsland Markdown, FluentAvalonia, FluentIcons, and Material-related dependencies.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Centralized Avalonia 12 dependency baseline
|
||||
|
||||
The solution SHALL use central package management for direct Avalonia-facing projects and keep the core UI dependency baseline on Avalonia `12.0.1`.
|
||||
|
||||
Required package baseline:
|
||||
|
||||
- `Avalonia*` `12.0.1`
|
||||
- `Avalonia.Controls.WebView` `12.0.0`
|
||||
- `ClassIsland.Markdown.Avalonia` `12.0.0`
|
||||
- `FluentAvaloniaUI` `3.0.0-preview1`
|
||||
- `FluentIcons.Avalonia` `2.1.325`
|
||||
- `Material.Avalonia` `3.16.0`
|
||||
- `Material.Icons.Avalonia` `3.0.2`
|
||||
|
||||
### Requirement: Official WebView
|
||||
|
||||
The host SHALL use `Avalonia.Controls.NativeWebView` for the browser widget and SHALL NOT reference `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
|
||||
|
||||
Windows WebView2 user data configuration SHALL be supplied through `EnvironmentRequested` using `WindowsWebView2EnvironmentRequestedEventArgs.UserDataFolder`.
|
||||
|
||||
### Requirement: Plugin SDK v5
|
||||
|
||||
The Plugin SDK API baseline SHALL be `5.0.0`. SDK v4 plugins are treated as incompatible until rebuilt.
|
||||
|
||||
The SDK SHALL keep the existing public UI extension shape, including `SettingsPageBase` and Avalonia `Control` based desktop components.
|
||||
|
||||
### Requirement: Launcher data location stability
|
||||
|
||||
Launcher data location configuration SHALL be read from a fixed bootstrap Launcher data directory so resolving the selected data root cannot recursively require resolving itself.
|
||||
|
||||
### Requirement: OOBE state compatibility
|
||||
|
||||
The Launcher SHALL read current OOBE state from the resolved `Launcher/state` directory and SHALL continue to migrate the legacy `.launcher/state/first_run_completed` marker.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `dotnet build LanMountainDesktop.slnx -c Debug` completes with 0 errors.
|
||||
- `OobeStateServiceTests` pass.
|
||||
- Full `dotnet test LanMountainDesktop.slnx -c Debug` no longer aborts from `DataLocationResolver` recursion.
|
||||
- Plugin template defaults to SDK package version `5.0.0` and manifest `apiVersion` `5.0.0`.
|
||||
- Current developer documentation points to SDK v5 and Avalonia 12.
|
||||
18
.trae/specs/avalonia-12-migration/tasks.md
Normal file
18
.trae/specs/avalonia-12-migration/tasks.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Centralize Avalonia 12 package versions in `Directory.Packages.props`.
|
||||
- [x] Move the host, Launcher, Plugin SDK, DesktopHost, Shared.Contracts, and Avalonia-facing projects onto central package versions.
|
||||
- [x] Replace third-party `WebView.Avalonia` usage with official `NativeWebView`.
|
||||
- [x] Configure WebView2 user data through `EnvironmentRequested`.
|
||||
- [x] Move FluentAvalonia usages to the FA3 control names and package baseline.
|
||||
- [x] Move FluentIcons usage to `FluentIcons.Avalonia` and remove the old `.Fluent` package.
|
||||
- [x] Update Plugin SDK package version and API baseline to `5.0.0`.
|
||||
- [x] Update plugin runtime shared assembly policy for Avalonia 12 / FluentAvalonia / FluentIcons / Material.
|
||||
- [x] Fix Avalonia 12 compile breaks in window chrome, binding plugin access, clipboard, bitmap copy, and icon source usage.
|
||||
- [x] Fix Launcher data location recursion by using a fixed bootstrap config path.
|
||||
- [x] Fix OOBE state tests and legacy marker compatibility.
|
||||
- [x] Update PluginTemplate defaults to SDK v5.
|
||||
- [x] Add SDK v5 migration documentation.
|
||||
- [x] Update current docs from SDK v4 / Avalonia 11 examples to SDK v5 / Avalonia 12.
|
||||
- [x] Run full solution tests and record any remaining non-upgrade failures.
|
||||
- [ ] Perform Windows manual smoke test for host, Launcher, settings, component editor, BrowserWidget, and WebView2 missing-runtime handling.
|
||||
13
.trae/specs/clock-air-app-mvp/checklist.md
Normal file
13
.trae/specs/clock-air-app-mvp/checklist.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Checklist
|
||||
|
||||
- [x] Clicking `DesktopClock` and `DesktopWorldClock` opens the same global Clock Air APP type.
|
||||
- [x] Repeated `world-clock` open requests use the global `world-clock:clock-suite:global` instance key.
|
||||
- [x] Whiteboard Air APP keeps its per-component instance key behavior.
|
||||
- [x] Clock Air APP opens as a normal application window, not a desktop-layer window.
|
||||
- [x] Clock Air APP settings are independent from desktop clock widget settings.
|
||||
- [x] Corrupt Clock Air APP settings fall back to defaults.
|
||||
- [x] World clock time labels support 12-hour, 24-hour, and follow-system formatting.
|
||||
- [x] Added localization keys are present in all four supported language files.
|
||||
- [x] Build and automated tests pass.
|
||||
- [ ] Manual visual verification in all four languages.
|
||||
- [ ] Manual verification that minimizing keeps stopwatch and timer running while closing stops them.
|
||||
42
.trae/specs/clock-air-app-mvp/spec.md
Normal file
42
.trae/specs/clock-air-app-mvp/spec.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Clock Air APP MVP
|
||||
|
||||
## Goal
|
||||
|
||||
Upgrade the built-in `world-clock` Air APP into a focused clock suite while keeping desktop clock widgets as lightweight launch entry points.
|
||||
|
||||
## Scope
|
||||
|
||||
- Keep the existing Air APP id `world-clock` for Launcher lifecycle compatibility.
|
||||
- Use one global Clock Air APP instance for every clock widget entry point.
|
||||
- Provide four tabs: World Clock, Stopwatch, Timer, and Settings.
|
||||
- Store Clock Air APP settings independently from desktop widget settings at `AirApps/Clock/settings.json`.
|
||||
- Follow the host language setting and provide localized text for `zh-CN`, `en-US`, `ja-JP`, and `ko-KR`.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `world-clock` opens as a standard resizable FluentAvalonia window.
|
||||
- The default window size is approximately `780x560`, with a minimum of `680x480`.
|
||||
- World Clock shows local time and a configurable city list.
|
||||
- Default city list is Beijing, London, Sydney, and New York.
|
||||
- Users can add, remove, and reorder city entries during the Air APP session; the list persists across restarts.
|
||||
- Stopwatch supports start, pause, resume, lap, and reset; laps are kept in the current window session, up to 50 entries.
|
||||
- Timer supports fixed presets, a custom minute duration, start, pause, resume, reset, and a completed state.
|
||||
- Closing the Clock Air APP stops stopwatch and timer activity.
|
||||
- Minimizing the window keeps stopwatch and timer activity running.
|
||||
- Timer completion can activate the Clock Air APP window when the setting is enabled.
|
||||
|
||||
## Settings
|
||||
|
||||
- Time format: follow system, 24-hour, or 12-hour.
|
||||
- Show seconds.
|
||||
- Startup tab: last used tab, World Clock, Stopwatch, or Timer.
|
||||
- Activate window when timer finishes.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Desktop clock widget visual redesign.
|
||||
- Alarms.
|
||||
- Focus mode.
|
||||
- System notifications.
|
||||
- Running stopwatch or timer after the Air APP window is closed.
|
||||
- Third-party plugin Air APP declarations.
|
||||
15
.trae/specs/clock-air-app-mvp/tasks.md
Normal file
15
.trae/specs/clock-air-app-mvp/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Add Clock Air APP settings snapshot and JSON store.
|
||||
- [x] Add shared Clock Air APP time formatting helpers.
|
||||
- [x] Add stopwatch and timer state models with focused tests.
|
||||
- [x] Replace the old world-clock view with `ClockAirAppView`.
|
||||
- [x] Configure `world-clock` as a standard resizable Air APP window.
|
||||
- [x] Make `world-clock` use a global single-instance key independent of source component id.
|
||||
- [x] Add world clock city add, remove, and reorder behavior.
|
||||
- [x] Add stopwatch tab with lap support.
|
||||
- [x] Add timer tab with presets and custom duration.
|
||||
- [x] Add independent Clock Air APP settings tab.
|
||||
- [x] Add `zh-CN`, `en-US`, `ja-JP`, and `ko-KR` localization keys.
|
||||
- [x] Ensure AirAppHost output includes localization JSON resources.
|
||||
- [x] Add regression tests for Launcher keying, descriptors, settings, formatting, stopwatch, timer, and localization coverage.
|
||||
104
.trae/specs/data-settings-page/design.md
Normal file
104
.trae/specs/data-settings-page/design.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 数据设置页设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
在设置窗口中新增「数据」设置页,用于可视化展示和管理阑山桌面产生的各类本地数据。采用 Fluent Design 风格的横向堆叠条形图展示存储分布。
|
||||
|
||||
## 设计目标
|
||||
|
||||
1. 让用户直观了解阑山桌面占用的存储空间
|
||||
2. 提供各类数据的占比可视化
|
||||
3. 支持按类别清理数据
|
||||
4. 显示相对于磁盘总容量的占比
|
||||
|
||||
## 页面结构
|
||||
|
||||
### 存储概览区域
|
||||
|
||||
顶部一个卡片,包含:
|
||||
- **横向堆叠条形图** — 各类数据用不同颜色的分段表示
|
||||
- **总占用大小** — 阑山桌面数据总大小(如 "1.2 GB")
|
||||
- **磁盘占比** — 占总磁盘空间的百分比(如 "占 C 盘 0.5%")
|
||||
- **图例** — 各颜色对应的数据类型
|
||||
|
||||
### 数据类型详情列表
|
||||
|
||||
下方列表展示每类数据:
|
||||
- 图标 + 名称
|
||||
- 占用大小
|
||||
- 描述/路径提示
|
||||
- 「清理」按钮(如适用)
|
||||
|
||||
### 操作按钮
|
||||
|
||||
- 「刷新」— 重新扫描数据大小
|
||||
- 「一键清理」— 清理所有可清理的数据
|
||||
|
||||
## 数据类型
|
||||
|
||||
| 类型 | 颜色 | 可清理 | 路径 |
|
||||
|------|------|--------|------|
|
||||
| 日志文件 | 灰色 | 是 | `log/` |
|
||||
| 白板笔记 | 橙色 | 是(过期) | `Whiteboards/` |
|
||||
| 插件数据 | 蓝色 | 是 | `Extensions/Plugins/` |
|
||||
| 插件市场缓存 | 紫色 | 是 | `PluginMarket/` |
|
||||
| 壁纸文件 | 粉色 | 是 | `Wallpapers/` |
|
||||
| 设置文件 | 绿色 | 否 | `settings.json` |
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 新增文件
|
||||
|
||||
- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` — 页面视图
|
||||
- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` — 页面代码隐藏
|
||||
- `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` — 视图模型
|
||||
- `LanMountainDesktop/Services/DataStorageService.cs` — 数据扫描服务
|
||||
|
||||
### 修改文件
|
||||
|
||||
- `LanMountainDesktop/Views/SettingsWindow.axaml.cs` — 图标映射(MapIcon)添加 Database 图标
|
||||
|
||||
### 设置页注册
|
||||
|
||||
```csharp
|
||||
[SettingsPageInfo(
|
||||
"data",
|
||||
"Data",
|
||||
SettingsPageCategory.General,
|
||||
IconKey = "Database",
|
||||
SortOrder = 5,
|
||||
TitleLocalizationKey = "settings.data.title",
|
||||
DescriptionLocalizationKey = "settings.data.description")]
|
||||
```
|
||||
|
||||
## 视觉设计
|
||||
|
||||
### 堆叠条形图
|
||||
|
||||
- 高度:24-32dp
|
||||
- 圆角:使用 `DesignCornerRadiusSm`
|
||||
- 分段间距:2dp
|
||||
- 未占用空间:透明或浅色背景
|
||||
|
||||
### 颜色方案
|
||||
|
||||
使用 Material Design 颜色,与主题协调:
|
||||
- 日志:Gray / BlueGray
|
||||
- 白板:Orange / Amber
|
||||
- 插件:Blue / Indigo
|
||||
- 缓存:Purple / DeepPurple
|
||||
- 壁纸:Pink
|
||||
- 设置:Green / Teal
|
||||
|
||||
## 交互行为
|
||||
|
||||
1. 页面加载时自动扫描数据大小(异步)
|
||||
2. 显示加载指示器
|
||||
3. 清理操作需要确认对话框
|
||||
4. 清理完成后自动刷新数据
|
||||
|
||||
## 安全考虑
|
||||
|
||||
- 清理前确认用户意图
|
||||
- 设置文件不可清理(防止误删配置)
|
||||
- 清理操作记录日志
|
||||
777
.trae/specs/data-settings-page/plan.md
Normal file
777
.trae/specs/data-settings-page/plan.md
Normal file
@@ -0,0 +1,777 @@
|
||||
# 数据设置页实现计划
|
||||
|
||||
> **Goal:** 在设置窗口中新增「数据」设置页,可视化展示阑山桌面各类本地数据的存储占用,支持数据清理。
|
||||
|
||||
> **Architecture:** 采用 MVVM 模式,新增 DataStorageService 负责异步扫描各类数据大小,DataSettingsPage 使用 Fluent Design 横向堆叠条形图展示存储分布。
|
||||
|
||||
> **Tech Stack:** Avalonia UI, FluentAvaloniaUI, CommunityToolkit.Mvvm, C# 13
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `LanMountainDesktop/Services/DataStorageService.cs` | 扫描各类数据目录大小,计算磁盘总容量 |
|
||||
| `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` | 数据设置页视图模型,绑定存储数据和清理命令 |
|
||||
| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` | 数据设置页 XAML 视图(堆叠条形图 + 列表) |
|
||||
| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` | 页面代码隐藏,注册设置页属性 |
|
||||
| `LanMountainDesktop/Views/SettingsWindow.axaml.cs` | 修改图标映射,添加 Database 图标 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 DataStorageService
|
||||
|
||||
**Files:**
|
||||
- Create: `LanMountainDesktop/Services/DataStorageService.cs`
|
||||
|
||||
**职责:** 扫描阑山桌面各类数据的存储占用,计算磁盘总容量。
|
||||
|
||||
- [ ] **Step 1: 创建 DataStorageService**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record StorageCategoryInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string DirectoryPath,
|
||||
bool IsCleanable,
|
||||
string ColorHex);
|
||||
|
||||
public sealed record StorageScanResult(
|
||||
StorageCategoryInfo Category,
|
||||
long SizeBytes,
|
||||
double PercentageOfTotal);
|
||||
|
||||
public sealed class DataStorageService
|
||||
{
|
||||
private static readonly IReadOnlyList<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. 点击「数据」选项卡,确认:
|
||||
- 堆叠条形图显示各类数据占比
|
||||
- 总大小和磁盘占比显示正确
|
||||
- 数据详情列表显示每类数据大小
|
||||
- 刷新按钮可以重新扫描
|
||||
- 清理按钮可以清理对应数据
|
||||
13
.trae/specs/dock-back-to-windows-button-display/checklist.md
Normal file
13
.trae/specs/dock-back-to-windows-button-display/checklist.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Checklist
|
||||
|
||||
- [ ] `AppSettingsSnapshot.BackToWindowsButtonDisplayMode` exists and defaults to `IconAndText`.
|
||||
- [ ] `AppSettingsSnapshot` contains icon source, Fluent icon name, and text icon settings with safe defaults.
|
||||
- [ ] General > Basic Settings includes one folded back-to-platform button settings expander.
|
||||
- [ ] The expander includes the display-mode dropdown.
|
||||
- [ ] The expander includes nested icon source, Fluent icon popup picker, and text icon input controls.
|
||||
- [ ] The Dock button left icon slot renders either a Fluent icon or custom text.
|
||||
- [ ] `IconAndText`, `IconOnly`, and `TextOnly` modes update the Dock button live.
|
||||
- [ ] Icon source, Fluent icon name, and text icon updates refresh the Dock button live.
|
||||
- [ ] The selected mode is preserved when MainWindow saves app settings.
|
||||
- [ ] Localization keys exist for zh-CN, en-US, ja-JP, and ko-KR.
|
||||
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` succeeds.
|
||||
29
.trae/specs/dock-back-to-windows-button-display/spec.md
Normal file
29
.trae/specs/dock-back-to-windows-button-display/spec.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Dock Back To Windows Button Display
|
||||
|
||||
## Summary
|
||||
|
||||
The Dock "Back to platform" action should expose a configurable left icon slot while keeping the localized platform text fixed.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The default display mode is `IconAndText` so existing users keep a familiar Dock layout after upgrade.
|
||||
- The localized platform text remains controlled by the app and is not user-editable.
|
||||
- General > Basic Settings exposes one Fluent Avalonia `FASettingsExpander` for the back-to-platform button, with icon-related controls folded into nested `FASettingsExpanderItem` rows.
|
||||
- The main row exposes a dropdown with `IconAndText`, `IconOnly`, and `TextOnly` options.
|
||||
- A nested icon source row selects Fluent icon or text icon.
|
||||
- Fluent icon mode uses a popup picker-style flyout with search and a grid of the full FluentIcons `Icon` enum.
|
||||
- Text icon mode lets the user enter short text for the left icon slot.
|
||||
- Changing the dropdown persists to `AppSettingsSnapshot.BackToWindowsButtonDisplayMode` and updates the Dock button without restarting.
|
||||
- Changing the icon source, Fluent icon, or text icon persists to app settings and updates the Dock button without restarting.
|
||||
- `IconOnly` keeps the existing tooltip text so the button remains understandable.
|
||||
- `PinnedTaskbarActions` continues to control whether the action is visible; it does not replace the display mode setting.
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- With default settings, the Dock button shows a small circle icon and the localized platform text.
|
||||
- Selecting icon only hides the platform text and keeps the configured left icon visible.
|
||||
- Selecting text only hides the left icon slot and keeps the localized platform text visible.
|
||||
- Choosing a Fluent icon changes the left icon slot.
|
||||
- Entering a short text icon changes the left icon slot.
|
||||
- Restarting the app restores the selected display mode.
|
||||
- Clicking the button still runs the existing minimize/back-to-platform behavior.
|
||||
@@ -0,0 +1,14 @@
|
||||
- [x] ComponentCategoryIconResolver 基于 IconKey 正确解析分类图标
|
||||
- [x] IconKey 为 "Clock" 时解析为 Icon.Clock
|
||||
- [x] IconKey 为 "WeatherSunny" 时解析为 Icon.WeatherSunny
|
||||
- [x] IconKey 为 "News" 时解析为 Icon.News
|
||||
- [x] IconKey 为 "Edit" 时解析为 Icon.Edit
|
||||
- [x] IconKey 为无效值时回退到 Icon.Apps
|
||||
- [x] 分类 ID 为 "all" 时返回 Icon.Apps
|
||||
- [x] ComponentLibraryCategoryViewModel.Icon 类型为 FluentIcons.Common.Icon
|
||||
- [x] FusedDesktopComponentLibraryControl.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法
|
||||
- [x] ComponentLibraryWindow.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法
|
||||
- [x] MainWindow.ComponentSystem.cs 不再包含硬编码 ResolveComponentLibraryCategoryIcon 方法
|
||||
- [x] 三处组件库入口对同一分类显示相同图标
|
||||
- [x] dotnet build 无编译错误
|
||||
- [x] dotnet test 全部通过
|
||||
73
.trae/specs/fused-desktop-category-icon-unification/spec.md
Normal file
73
.trae/specs/fused-desktop-category-icon-unification/spec.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 融合桌面组件库分类图标统一规格
|
||||
|
||||
## Why
|
||||
|
||||
融合桌面组件库窗口(FusedDesktopComponentLibraryControl)的分类图标使用了手动硬编码的 `ResolveCategoryIcon` 方法映射分类 ID 到 `Symbol` 枚举,与阑山桌面主窗口(MainWindow)中的映射存在不一致(例如 `Info` 分类在主窗口映射到 `Symbol.Apps`,在融合桌面映射到 `Symbol.Info`)。同时,`DesktopComponentDefinition.IconKey` 字段已经存储了正确的 FluentIcon 枚举名称字符串,但未被利用。需要统一三处图标映射逻辑,确保所有组件库入口的分类图标一致且正确。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **统一分类图标映射**:将三处分散的 `ResolveCategoryIcon`/`ResolveComponentLibraryCategoryIcon` 方法合并为共享的统一映射
|
||||
- **使用 `IconKey` 驱动图标**:分类图标应基于该分类下组件的 `IconKey` 字段推导,而非硬编码的分类 ID 映射
|
||||
- **使用 `FluentIcons.Common.Icon` 枚举**:`fi:FluentIcon` 控件使用 `Icon` 枚举(非 `Symbol` 枚举),分类图标应使用 `Icon` 枚举以与 `fi:FluentIcon` 兼容
|
||||
- **修改 ViewModel**:`ComponentLibraryCategoryViewModel.Icon` 属性类型从 `Symbol` 改为 `Icon`
|
||||
|
||||
## Impact
|
||||
|
||||
- 受影响文件:
|
||||
- `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(Icon 属性类型从 Symbol 改为 Icon)
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`(绑定路径不变,但 Icon 类型变化)
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`(移除硬编码映射,使用统一方法)
|
||||
- `LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs`(移除硬编码映射,使用统一方法)
|
||||
- `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`(移除硬编码映射,使用统一方法)
|
||||
- 新增共享映射工具类(或在现有服务中添加)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 统一分类图标映射
|
||||
|
||||
系统 SHALL 提供一个共享的分类图标映射方法,所有组件库入口(阑山桌面主窗口、融合桌面组件库、独立组件库窗口)均使用此方法。
|
||||
|
||||
#### Scenario: 图标映射来源
|
||||
- **GIVEN** 一个组件分类 ID
|
||||
- **WHEN** 需要获取该分类的图标
|
||||
- **THEN** 系统应基于该分类下组件的 `IconKey` 字段推导分类图标
|
||||
- **AND** 推导规则为:取该分类下第一个组件的 `IconKey`,解析为 `FluentIcons.Common.Icon` 枚举值
|
||||
- **AND** 若 `IconKey` 无法解析为有效的 `Icon` 枚举值,则回退到 `Icon.Apps`
|
||||
|
||||
#### Scenario: 特殊分类处理
|
||||
- **GIVEN** 分类 ID 为 "all"
|
||||
- **WHEN** 需要获取该分类的图标
|
||||
- **THEN** 系统应返回 `Icon.Apps`
|
||||
|
||||
#### Scenario: 三处映射一致性
|
||||
- **GIVEN** 任意一个组件分类
|
||||
- **WHEN** 在阑山桌面主窗口、融合桌面组件库、独立组件库窗口中显示该分类
|
||||
- **THEN** 三处应显示完全相同的图标
|
||||
|
||||
### Requirement: ViewModel 使用 Icon 枚举
|
||||
|
||||
`ComponentLibraryCategoryViewModel.Icon` 属性 SHALL 使用 `FluentIcons.Common.Icon` 枚举类型(而非 `FluentIcons.Common.Symbol`),以与 `fi:FluentIcon` 控件的 `Icon` 属性兼容。
|
||||
|
||||
#### Scenario: XAML 绑定兼容
|
||||
- **GIVEN** `ComponentLibraryCategoryViewModel.Icon` 属性类型为 `Icon`
|
||||
- **WHEN** 在 XAML 中通过 `{Binding Icon}` 绑定到 `fi:FluentIcon` 控件
|
||||
- **THEN** 图标应正确渲染,无需额外转换
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 分类图标解析
|
||||
|
||||
原实现使用硬编码的 `if/switch` 语句将分类 ID 映射到 `Symbol` 枚举,新实现改为:
|
||||
|
||||
- 使用 `DesktopComponentDefinition.IconKey` 字段作为图标来源
|
||||
- 通过 `Enum.TryParse<Icon>(iconKey, ignoreCase: true, out var icon)` 解析
|
||||
- 解析失败时回退到 `Icon.Apps`
|
||||
- 移除所有三处硬编码映射方法
|
||||
|
||||
### Requirement: ComponentLibraryCategoryViewModel.Icon 类型
|
||||
|
||||
原类型为 `Symbol`,修改为 `Icon`,与 `fi:FluentIcon` 控件的 `Icon` 依赖属性类型一致。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
无移除的需求。
|
||||
38
.trae/specs/fused-desktop-category-icon-unification/tasks.md
Normal file
38
.trae/specs/fused-desktop-category-icon-unification/tasks.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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 依赖于所有前置任务
|
||||
12
.trae/specs/launcher-managed-air-app-lifecycle/checklist.md
Normal file
12
.trae/specs/launcher-managed-air-app-lifecycle/checklist.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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.
|
||||
24
.trae/specs/launcher-managed-air-app-lifecycle/spec.md
Normal file
24
.trae/specs/launcher-managed-air-app-lifecycle/spec.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
13
.trae/specs/launcher-managed-air-app-lifecycle/tasks.md
Normal file
13
.trae/specs/launcher-managed-air-app-lifecycle/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -3,6 +3,7 @@
|
||||
- [ ] New install shows OOBE once.
|
||||
- [ ] Same-user reinstall does not show OOBE again.
|
||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
||||
- [ ] `plugin-install` does 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).
|
||||
|
||||
@@ -23,12 +23,11 @@ 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.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to:
|
||||
- the installer itself
|
||||
- full installer update application
|
||||
|
||||
@@ -65,3 +65,19 @@
|
||||
- 托盘失败时应用仍保持可恢复。
|
||||
- Launcher 与应用设置页显示相同版本。
|
||||
- 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口入场、通知位置与动画正常。
|
||||
|
||||
### 5. Launcher IPC and error surface follow-up
|
||||
|
||||
- The legacy `LanMountainDesktop_Launcher` named-pipe startup progress channel is retired. Public IPC notifications and host exit codes are the only startup state sources.
|
||||
- Normal Launcher launches must probe public IPC for an existing Host before starting a new Host process. Host no longer owns multi-instance policy, activation prompts, or the old single-instance pipe.
|
||||
- `SecondaryActivationSucceeded` is a success terminal state. `SecondaryActivationFailed` and `RestartLockNotAcquired` may surface as failures only after public IPC recovery has failed.
|
||||
- Launcher startup errors must use FluentAvalonia resources, Fluent icons, an InfoBar recovery hint, and copyable diagnostics instead of the old hard-coded dark panel.
|
||||
|
||||
### 6. Multi-instance behavior setting
|
||||
|
||||
- App settings include `MultiInstanceLaunchBehavior` with default `NotifyAndOpenDesktop`.
|
||||
- General settings exposes the behavior under Basic Settings with four choices: restart app, open desktop silently, prompt only, and notify plus open desktop.
|
||||
- Launcher reads the Host `settings.json` before a normal launch and applies the selected behavior when public IPC reports an existing Host.
|
||||
- `PromptOnly` shows a Fluent Launcher prompt and does not open the desktop automatically.
|
||||
- `NotifyAndOpenDesktop` activates the existing Host and shows the already-running notice from Launcher.
|
||||
- `RestartApp` requests restart through public IPC and must not create a second Host if the restart request fails.
|
||||
|
||||
@@ -12,3 +12,10 @@
|
||||
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
|
||||
- [x] 补充规格与版本同步说明文档。
|
||||
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
|
||||
|
||||
- [x] Remove the legacy `LanMountainDesktop_Launcher` startup progress pipe; launcher progress now uses public IPC plus host exit-code classification only.
|
||||
- [x] Move normal multi-open probing into Launcher before host launch and remove Host-side single-instance prompt/listener code.
|
||||
- [x] Refresh the Launcher error window with Fluent resources, InfoBar, Fluent icons, command bar actions, and copyable diagnostic details.
|
||||
- [x] Add app-level `MultiInstanceLaunchBehavior` setting and expose it in General > Basic Settings.
|
||||
- [x] Make Launcher apply restart/open silently/prompt only/notify and open behavior before starting a new Host.
|
||||
- [x] Add a Fluent Launcher multi-instance prompt; Host public IPC stays limited to activation/status/restart/exit actions.
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
- Tray menu `Exit App` must commit an irreversible host shutdown request.
|
||||
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
|
||||
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
|
||||
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, and telemetry resources before the forced-exit deadline.
|
||||
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
|
||||
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
|
||||
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
|
||||
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to perform multi-instance detection through public IPC.
|
||||
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
|
||||
- Repeated tray clicks during shutdown are ignored and logged.
|
||||
- Repeated component-library clicks focus the existing window instead of opening duplicates.
|
||||
|
||||
42
.trae/specs/localization-fix/checklist.md
Normal file
42
.trae/specs/localization-fix/checklist.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 本地化修复 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]'` 等检查)
|
||||
85
.trae/specs/localization-fix/spec.md
Normal file
85
.trae/specs/localization-fix/spec.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 本地化修复 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 显示"自定义颜色"
|
||||
39
.trae/specs/localization-fix/tasks.md
Normal file
39
.trae/specs/localization-fix/tasks.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 本地化修复 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 检查是否有遗漏的硬编码英文(通过正则搜索)
|
||||
42
.trae/specs/main-window-desktop-layer/design.md
Normal file
42
.trae/specs/main-window-desktop-layer/design.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Main Window Desktop Layer Design
|
||||
|
||||
## Window Roles
|
||||
|
||||
Lan Mountain Desktop now has three separate window-layer roles:
|
||||
|
||||
- `MainDesktopWindow`: the normal desktop host window. With `EnableMainWindowDesktopLayer`, this window is moved to the desktop layer so it does not cover ordinary apps.
|
||||
- `FusedDesktopSurface`: fused desktop component windows such as `DesktopWidgetWindow` and `TransparentOverlayWindow`. These continue to use `IWindowBottomMostService` and their existing click-through region service.
|
||||
- `AirApp`: independent Air APP windows. These are ordinary app windows and do not use desktop-layer services or global `Topmost` promotion.
|
||||
|
||||
## Service Boundary
|
||||
|
||||
`IMainWindowDesktopLayerService` is dedicated to the main window only. It does not reuse fused desktop passthrough services because the main window must stay interactive.
|
||||
|
||||
Windows behavior:
|
||||
|
||||
- Save original parent, style, and extended style before enabling.
|
||||
- Try to attach the main window to the desktop icon host.
|
||||
- If that host is not found, use `HWND_BOTTOM`.
|
||||
- On disable, restore the saved parent and styles as best effort.
|
||||
|
||||
Non-Windows behavior:
|
||||
|
||||
- Keep a null implementation.
|
||||
- Log that the platform is unsupported.
|
||||
|
||||
## Settings Flow
|
||||
|
||||
The developer settings page owns confirmation UX for conflicts:
|
||||
|
||||
- Fused desktop toggle and main-window desktop-layer toggle are one-way bound.
|
||||
- Toggle click handlers ask for confirmation before saving conflicting states.
|
||||
- The view model writes both keys together so runtime listeners receive a coherent change set.
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
Main-window restore paths call `ActivateOrRefreshMainWindowLayer`.
|
||||
|
||||
- If `EnableMainWindowDesktopLayer` is enabled, the app refreshes the desktop-layer attachment and hides the taskbar entry.
|
||||
- If disabled, the app restores ordinary activation behavior, including the existing temporary foreground promotion.
|
||||
|
||||
Settings changes call both fused desktop and main-window desktop-layer runtime application paths so switching modes is immediate.
|
||||
20
.trae/specs/main-window-desktop-layer/requirements.md
Normal file
20
.trae/specs/main-window-desktop-layer/requirements.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Main Window Desktop Layer
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add a developer option named `EnableMainWindowDesktopLayer`.
|
||||
- When enabled, the main Lan Mountain desktop window behaves like a desktop-surface window: ordinary application windows can stay above it.
|
||||
- The feature is implemented as desktop-layer or bottom placement, not as `Topmost`.
|
||||
- The option is mutually exclusive with `EnableFusedDesktop`.
|
||||
- Enabling main-window desktop layer while fused desktop is enabled must ask for confirmation, then disable fused desktop on confirm or roll back on cancel.
|
||||
- Enabling fused desktop while main-window desktop layer is enabled must ask for confirmation, then disable main-window desktop layer on confirm or roll back on cancel.
|
||||
- Air APP windows remain ordinary application windows and must not be attached to the desktop layer.
|
||||
- On Windows, the main window should attach to the desktop icon host when available and fall back to `HWND_BOTTOM` when unavailable.
|
||||
- On non-Windows platforms, the setting may exist but the layer service is a no-op and must not throw.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Opening another app above Lan Mountain Desktop keeps that app visible when main-window desktop layer is enabled.
|
||||
- Restoring the main window from tray keeps the desktop-layer behavior and does not perform a temporary `Topmost` promotion.
|
||||
- Turning the option off restores normal main-window behavior as far as possible.
|
||||
- Fused desktop component windows keep their existing bottom-most behavior and remain isolated from the main-window service.
|
||||
10
.trae/specs/main-window-desktop-layer/tasks.md
Normal file
10
.trae/specs/main-window-desktop-layer/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Main Window Desktop Layer Tasks
|
||||
|
||||
- [x] Add `EnableMainWindowDesktopLayer` to app settings with a disabled default.
|
||||
- [x] Add developer settings UI and localization strings.
|
||||
- [x] Add confirmation flow for mutual exclusion with fused desktop.
|
||||
- [x] Add a dedicated main-window desktop-layer service.
|
||||
- [x] Wire main-window creation, restore, tray fallback, settings changes, and shutdown cleanup to the service.
|
||||
- [x] Keep Air APP windows outside this layer service.
|
||||
- [x] Add static regression tests for settings, restore paths, and service boundaries.
|
||||
- [ ] Perform manual Windows z-order validation with real apps.
|
||||
12
.trae/specs/material-color-service/checklist.md
Normal file
12
.trae/specs/material-color-service/checklist.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Material Color Service Acceptance Checklist
|
||||
|
||||
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` succeeds.
|
||||
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` succeeds.
|
||||
- [x] Material & Color page exposes color source, wallpaper source, system material, native event preference, polling interval, manual refresh, semantic color preview, and surface preview.
|
||||
- [x] Appearance page no longer owns duplicate visible color/material controls.
|
||||
- [x] Appearance page view model preserves Material & Color settings instead of rewriting them.
|
||||
- [x] Component corner-radius settings preserve Material & Color fields instead of resetting them through old positional constructors.
|
||||
- [x] Component editor receives colors from `MaterialColorSnapshot`.
|
||||
- [x] Plugin SDK snapshot includes read-only color/material fields without breaking the existing constructor shape.
|
||||
- [x] Wallpaper source selection supports auto, app, and system modes.
|
||||
- [x] Native wallpaper event monitoring can be disabled and polling remains available.
|
||||
62
.trae/specs/material-color-service/spec.md
Normal file
62
.trae/specs/material-color-service/spec.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Material Color Service
|
||||
|
||||
## Goal
|
||||
|
||||
Unify Monet seed extraction, wallpaper color extraction, semantic color roles, host material surfaces, and plugin appearance snapshots behind one host-owned material/color source of truth.
|
||||
|
||||
## Scope
|
||||
|
||||
- Host service: `IMaterialColorService`
|
||||
- Compatibility facade: `IAppearanceThemeService`
|
||||
- Settings page: `MaterialColorSettingsPage`
|
||||
- Persisted settings:
|
||||
- `ThemeColorMode`
|
||||
- `ThemeColor`
|
||||
- `SelectedWallpaperSeed`
|
||||
- `SystemMaterialMode`
|
||||
- `ThemeWallpaperColorSource`
|
||||
- `UseNativeWallpaperChangeEvents`
|
||||
- `SystemWallpaperRefreshIntervalSeconds`
|
||||
- Plugin read-only appearance snapshot fields:
|
||||
- accent color
|
||||
- seed color
|
||||
- color source
|
||||
- system material mode
|
||||
- semantic color roles
|
||||
- material surfaces
|
||||
- wallpaper seed candidates
|
||||
|
||||
## Behavior
|
||||
|
||||
`IMaterialColorService` owns the live `MaterialColorSnapshot`. Consumers should derive colors and material values from this snapshot instead of recalculating from raw theme settings, wallpaper settings, or `MonetPalette`.
|
||||
|
||||
Supported color sources:
|
||||
|
||||
- `default_neutral`: stable neutral surfaces with the default accent.
|
||||
- `seed_monet`: user-selected seed color processed through Monet.
|
||||
- `wallpaper_monet`: wallpaper colors processed through Monet.
|
||||
|
||||
Wallpaper color source selection:
|
||||
|
||||
- `auto`: app wallpaper or app solid color first, then system wallpaper, then fallback.
|
||||
- `app`: app wallpaper or app solid color only, then fallback.
|
||||
- `system`: system wallpaper only, then fallback.
|
||||
|
||||
System wallpaper monitoring:
|
||||
|
||||
- Native Windows user preference events are preferred when enabled and available.
|
||||
- Polling remains active as the fallback path.
|
||||
- Manual refresh clears cached wallpaper candidates and rebuilds the snapshot.
|
||||
|
||||
## Refactor Rules
|
||||
|
||||
- New consumers must depend on `IMaterialColorService`, not on parallel combinations of theme settings, wallpaper settings, and `MonetColorService`.
|
||||
- `MonetColorService` remains the extraction/palette utility, not the application-wide coordinator.
|
||||
- Component/editor/plugin appearance code must consume `MaterialColorSnapshot` or a mapper produced from it.
|
||||
- Existing `IAppearanceThemeService` remains available for compatibility, but it must not become a second source of truth.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Plugin write access to global host appearance settings.
|
||||
- Market metadata or sample plugin changes.
|
||||
- Replacing the wallpaper picker page. It remains the asset/source management page.
|
||||
13
.trae/specs/material-color-service/tasks.md
Normal file
13
.trae/specs/material-color-service/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Material Color Service Tasks
|
||||
|
||||
- [x] Add unified material/color snapshot models and `IMaterialColorService`.
|
||||
- [x] Persist wallpaper color source and native wallpaper event preference.
|
||||
- [x] Add the Material & Color settings page.
|
||||
- [x] Keep Appearance focused on theme mode, window chrome, and corner radius.
|
||||
- [x] Route plugin appearance snapshots through the material/color snapshot.
|
||||
- [x] Route component editor theming through the material/color snapshot.
|
||||
- [x] Remove legacy color/material preview and save logic from the Appearance page view model.
|
||||
- [x] Replace legacy positional `ThemeAppearanceSettingsState` writes with preserving `with` updates where found.
|
||||
- [x] Keep native wallpaper events optional with polling/manual refresh fallback.
|
||||
- [x] Add regression tests for normalization, plugin mapping, and component editor palette mapping.
|
||||
- [ ] Continue retiring legacy direct consumers of raw theme/wallpaper/Monet tuples when they are touched.
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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.
|
||||
174
.trae/specs/plonds-client-service/spec.md
Normal file
174
.trae/specs/plonds-client-service/spec.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# PLONDS Client Service 独立化设计
|
||||
|
||||
> 日期:2026-06-01
|
||||
> 状态:设计中
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PLONDS 在应用内必须作为独立服务存在,负责分发发现、下载、校验和本地包准备。它不是现有 Update 模块的 provider,也不应把 S3/GitHub/source 选择逻辑混入 `LanMountainDesktop/Services/Update/`。
|
||||
|
||||
最终边界:
|
||||
|
||||
- PLONDS 服务:寻找最新版本、选择下载源、下载 manifest 和包、校验文件、准备本地 staging。
|
||||
- 安装程序/安装网关:只消费 PLONDS 已准备好的本地安装输入,执行增量安装或完整安装。
|
||||
- UI:只展示 PLONDS 服务和安装程序返回的状态;完整包也失败后才处理错误。
|
||||
|
||||
## 2. 当前耦合点
|
||||
|
||||
当前需要拆离的耦合点:
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||
- 直接持有 `PlondsStaticUpdateService` 与 `PlondsReleaseUpdateService`
|
||||
- 在 `CheckForUpdatesCoreAsync` 中把 PLONDS 和 GitHub Update fallback 逻辑混在一起
|
||||
- `LanMountainDesktop/Services/Update/UpdateInstallGateway.cs`
|
||||
- 直接判断 `UpdatePayloadKind.DeltaPlonds`
|
||||
- 直接实例化 `PlondsUpdateApplier`
|
||||
- `LanMountainDesktop/Services/Update/Plonds*.cs`
|
||||
- PLONDS apply/parser/payload resolver 仍位于 Update 命名空间
|
||||
|
||||
## 3. Source 发现规则
|
||||
|
||||
PLONDS 客户端内置两个初始地址:
|
||||
|
||||
1. S3 上的 PLONDS manifest 地址
|
||||
2. GitHub Release 上的 PLONDS manifest 地址
|
||||
|
||||
两个地址读取的是同一种 JSON 文件,当前文件名为 `PLONDS.json`。客户端每次检查增量更新时,会并行或顺序请求所有已知 source 的 `PLONDS.json`。
|
||||
|
||||
### 3.1 Source 扩展
|
||||
|
||||
`PLONDS.json` 可以声明额外 source。客户端读取到额外 source 后,应把它们加入下一轮寻找列表。
|
||||
|
||||
建议 manifest 扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "rainyun-s3",
|
||||
"kind": "s3",
|
||||
"manifestUrl": "https://example.com/plonds/1.2.3/PLONDS.json",
|
||||
"priority": 100
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"kind": "github",
|
||||
"manifestUrl": "https://github.com/owner/repo/releases/download/v1.2.3/PLONDS.json",
|
||||
"priority": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `sources` 为空或缺失时,只使用内置 S3 + GitHub。
|
||||
- 新 source 不覆盖内置 source,除非 `id` 相同。
|
||||
- source 列表需要去重,按 `id` 和 `manifestUrl` 双重去重。
|
||||
- source 持久化到 PLONDS 自己的配置/缓存,不写入 Update 设置。
|
||||
|
||||
## 4. 版本选择规则
|
||||
|
||||
如果多个 source 返回的版本不一致,客户端选择 `currentVersion` 最高的 manifest。
|
||||
|
||||
规则:
|
||||
|
||||
- 版本解析使用 `Version` 语义,忽略前导 `v`。
|
||||
- 版本相同时,优先选择下载可用性更高的 source。
|
||||
- 如果最高版本 manifest 下载包失败,可以尝试同版本的其他 source。
|
||||
- 不因为低版本 source 成功而降级,除非用户显式允许。
|
||||
|
||||
## 5. 下载与回退规则
|
||||
|
||||
PLONDS 服务优先走增量包:
|
||||
|
||||
1. 下载所选 manifest。
|
||||
2. 下载 `changed.zip`。
|
||||
3. 校验 `changed.zip` 与 manifest 中的 hash/checksum。
|
||||
4. 解压或准备增量 staging。
|
||||
5. 交给安装程序执行增量安装。
|
||||
|
||||
如果增量流程失败,PLONDS 服务自动改用完整包:
|
||||
|
||||
1. 下载 `Files.zip`。
|
||||
2. 校验 `Files.zip`。
|
||||
3. 解压或准备完整包 staging。
|
||||
4. 交给安装程序执行完整包安装。
|
||||
|
||||
如果完整包也失败,PLONDS 服务返回失败结果,由 UI 展示错误和重试入口。
|
||||
|
||||
## 6. 发布产物布局
|
||||
|
||||
Publisher 上传到 S3 的版本目录:
|
||||
|
||||
```text
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
||||
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
||||
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
|
||||
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
||||
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||
- Publisher 必须先完成版本目录内的 `changed.zip`、`Files.zip`、解压目录和版本 `PLONDS.json` 上传,再更新 `<prefix>/PLONDS.json` latest 指针。
|
||||
- Publisher 的 S3 目录上传必须支持重跑续传;同 key 且大小一致的对象可以跳过,避免失败后从头上传完整包目录。
|
||||
- Publisher 上传大对象时应使用 S3 multipart upload,以避免 `changed.zip` / `Files.zip` 在低吞吐链路上被单次 PUT 长时间阻塞。
|
||||
|
||||
## 7. 建议代码结构
|
||||
|
||||
```text
|
||||
LanMountainDesktop/Services/Plonds/
|
||||
IPlondsService.cs
|
||||
PlondsService.cs
|
||||
Sources/
|
||||
IPlondsSource.cs
|
||||
PlondsHttpManifestSource.cs
|
||||
PlondsSourceRegistry.cs
|
||||
Download/
|
||||
PlondsDownloader.cs
|
||||
PlondsDownloadPlanner.cs
|
||||
Verification/
|
||||
PlondsVerifier.cs
|
||||
Staging/
|
||||
PlondsPackageStore.cs
|
||||
PlondsPreparedPackage.cs
|
||||
Models/
|
||||
PlondsClientManifest.cs
|
||||
PlondsSourceDescriptor.cs
|
||||
PlondsCheckResult.cs
|
||||
```
|
||||
|
||||
后续如果要移植,优先把这棵目录或等价项目抽成独立库。
|
||||
|
||||
## 8. 与安装程序的交接契约
|
||||
|
||||
PLONDS 服务输出本地 prepared package:
|
||||
|
||||
```csharp
|
||||
public sealed record PlondsPreparedPackage(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string ManifestPath,
|
||||
string? ChangedZipPath,
|
||||
string? ChangedDirectory,
|
||||
string? FilesZipPath,
|
||||
string? FilesDirectory);
|
||||
```
|
||||
|
||||
安装程序只接受这个结果,不参与 source 发现、下载和校验。
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
1. Publisher 补齐完整包 S3 上传与 manifest downloads 字段。
|
||||
2. 新增 `Services/Plonds/` 客户端服务骨架和模型。
|
||||
3. 把 `PlondsStaticUpdateService` / `PlondsReleaseUpdateService` 合并迁移到独立 PLONDS source 体系。
|
||||
4. 把 `LanMountainDesktop/Services/Update/Plonds*.cs` 迁出 Update 命名空间。
|
||||
5. `UpdateSettingsService` 改为调用 `IPlondsService`,不再直接组合 S3/GitHub PLONDS fallback。
|
||||
6. 安装入口只接收 `PlondsPreparedPackage`。
|
||||
7. 添加单元测试覆盖 source 扩展、最高版本选择、增量失败转完整包、完整包失败交 UI。
|
||||
549
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
549
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# PLONDS Comparator 改造设计
|
||||
|
||||
> 日期:2026-05-30
|
||||
> 状态:待审批
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
PLONDS(Penguin 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-uploader.yml`):发布器,负责用仓库内 C# S3 客户端上传 `changed.zip`、`PLONDS.json` 和解压后的 `<version>-changed/` 目录,并把 GitHub/S3 下载信息写回 `PLONDS.json`
|
||||
- **Rollback**:独立 rollback 工作流已废弃,不再维护
|
||||
|
||||
### 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 Publisher 改造后步骤
|
||||
|
||||
```yaml
|
||||
# plonds-uploader.yml
|
||||
触发: PLONDS Comparator completed / workflow_dispatch
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- Checkout
|
||||
- 解析 release tag
|
||||
- Setup .NET
|
||||
- 构建 PLONDS Tool
|
||||
- 从 GitHub Release 下载 changed.zip + PLONDS.json
|
||||
- 调用 dotnet run Plonds.Tool -- publish-s3
|
||||
→ 使用仓库内 C# S3 客户端上传,不依赖 aws CLI
|
||||
→ S3 目录布局:
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
→ 回写 PLONDS.json downloads 字段:
|
||||
downloads.github.releaseUrl
|
||||
downloads.github.manifestUrl
|
||||
downloads.github.changedZipUrl
|
||||
downloads.github.filesZipUrl
|
||||
downloads.s3.manifestUrl
|
||||
downloads.s3.changedZipUrl
|
||||
downloads.s3.changedFolderUrl
|
||||
downloads.s3.filesZipUrl
|
||||
downloads.s3.filesFolderUrl
|
||||
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
|
||||
```
|
||||
|
||||
### 6.4 与当前步骤的差异
|
||||
|
||||
| 当前步骤 | 改造后 |
|
||||
|---------|--------|
|
||||
| 准备签名密钥 | ❌ 删除 |
|
||||
| 解析基线计划 (pwsh,三平台) | ✅ 简化:只找 Windows,逻辑简化 |
|
||||
| 下载 payload zips (pwsh,三平台) | ✅ 简化:只下载 Windows |
|
||||
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
|
||||
| 独立 rollback workflow | ❌ 删除 |
|
||||
|
||||
## 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. 对比哈希值,生成 filesMap(add/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. 不在本次改造范围内的事项
|
||||
|
||||
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||
- Launcher 侧客户端代码改造(后续单独设计)
|
||||
- Plonds.Api 项目处置(后续决定是否保留)
|
||||
- `build-index`、`generate`、`publish`、`sign` 等旧 Tool 命令的清理(后续处理)
|
||||
5
.trae/specs/runtime-packaging-fix/checklist.md
Normal file
5
.trae/specs/runtime-packaging-fix/checklist.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
12
.trae/specs/runtime-packaging-fix/spec.md
Normal file
12
.trae/specs/runtime-packaging-fix/spec.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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.
|
||||
7
.trae/specs/runtime-packaging-fix/tasks.md
Normal file
7
.trae/specs/runtime-packaging-fix/tasks.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 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.
|
||||
28
.trae/specs/settings-window-fluent-shell-redesign/spec.md
Normal file
28
.trae/specs/settings-window-fluent-shell-redesign/spec.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
13
.trae/specs/settings-window-fluent-shell-redesign/tasks.md
Normal file
13
.trae/specs/settings-window-fluent-shell-redesign/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Analyze current `SettingsWindow`, appearance theme service, and existing settings page layout.
|
||||
- [x] Compare ClassIsland `SettingsWindowNew` and SecRandom v3 Avalonia `SettingsView`.
|
||||
- [x] Replace footer fallback pane toggle with titlebar pane toggle.
|
||||
- [x] Add titlebar Back, search, restart, and more-options controls.
|
||||
- [x] Add settings navigation history.
|
||||
- [x] Add settings search service and result highlight.
|
||||
- [x] Add `auto` system material mode and Avalonia `TransparencyLevelHint` priority.
|
||||
- [x] Update appearance settings options and localization.
|
||||
- [x] Add focused tests for material normalization and search filtering.
|
||||
- [x] Add design/spec documentation.
|
||||
- [ ] Run full app manually on Windows 11 and Windows 10 to verify actual Mica/Acrylic backdrops.
|
||||
25
.trae/specs/update-settings-fluent-controls/spec.md
Normal file
25
.trae/specs/update-settings-fluent-controls/spec.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Update Settings Fluent Controls
|
||||
|
||||
## Goal
|
||||
|
||||
Make the Settings > Update page the single user-facing control surface for the host update flow.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The page uses Fluent Avalonia settings controls for update status, release facts, update behavior, and transfer controls.
|
||||
- Users can choose update channel, download source, update mode, and download thread count.
|
||||
- Update mode options are:
|
||||
- Manual: do not automatically download or install.
|
||||
- Silent Download: check and download in the background, then wait for user installation confirmation.
|
||||
- Silent Install: check and download in the background, then apply when the app exits.
|
||||
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
||||
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
||||
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
||||
- Existing PloNDS/FileMap incremental update 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`.
|
||||
@@ -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.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
- 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.
|
||||
|
||||
## Migration Note
|
||||
|
||||
|
||||
7
.trae/specs/window-layer-isolation/checklist.md
Normal file
7
.trae/specs/window-layer-isolation/checklist.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Checklist
|
||||
|
||||
- [x] Air APP window code does not call fused desktop bottom-most APIs.
|
||||
- [x] Air APP window code does not set `Topmost = true`.
|
||||
- [x] Fused desktop overlay and widget windows still use bottom-most APIs.
|
||||
- [x] Fused desktop widget reload path refreshes desktop layer after showing.
|
||||
- [ ] Manual Windows z-order verification with fused desktop and Air APP windows.
|
||||
18
.trae/specs/window-layer-isolation/spec.md
Normal file
18
.trae/specs/window-layer-isolation/spec.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Window Layer Isolation
|
||||
|
||||
## Goal
|
||||
|
||||
Keep fused desktop component windows and Air APP windows in separate z-order roles.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Fused desktop windows are desktop-surface windows. They may use `IWindowBottomMostService` and region passthrough, must stay attached to the Windows desktop icon host when supported, and must not cover ordinary apps.
|
||||
- Air APP windows are ordinary application windows. They must not use the fused desktop bottom-most service, must not attach to the desktop icon host, and must not use global `Topmost` promotion.
|
||||
- Re-showing or reloading fused desktop widgets refreshes their desktop layer after the window is visible.
|
||||
- Air APP activation uses normal window activation; repeated-open foreground recovery remains owned by Launcher lifecycle activation.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changing Air APP lifecycle IPC.
|
||||
- Changing whiteboard note sharing.
|
||||
- Implementing third-party Air APP SDK behavior.
|
||||
7
.trae/specs/window-layer-isolation/tasks.md
Normal file
7
.trae/specs/window-layer-isolation/tasks.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Remove Air APP `Topmost` promotion from `AirAppWindow`.
|
||||
- [x] Add explicit desktop-layer refresh for fused desktop widget windows.
|
||||
- [x] Refresh fused desktop widget windows after show/reload.
|
||||
- [x] Add window-role diagnostics for desktop-surface and Air APP windows.
|
||||
- [x] Add static regression tests for window-layer isolation.
|
||||
@@ -74,7 +74,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
|
||||
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
|
||||
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
|
||||
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V5_MIGRATION.md`
|
||||
|
||||
### 设置与主题
|
||||
|
||||
@@ -91,6 +91,6 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
- 视觉规范:`docs/VISUAL_SPEC.md`
|
||||
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
|
||||
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
|
||||
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
- SDK v5 迁移:`docs/PLUGIN_SDK_V5_MIGRATION.md`
|
||||
|
||||
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。
|
||||
|
||||
1577
CODE_WIKI.md
Normal file
1577
CODE_WIKI.md
Normal file
File diff suppressed because it is too large
Load Diff
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
10
CheckIpcAot/Program.cs
Normal file
10
CheckIpcAot/Program.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
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;} }
|
||||
43
Directory.Packages.props
Normal file
43
Directory.Packages.props
Normal file
@@ -0,0 +1,43 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<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="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="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||
<PackageVersion Include="Downloader" Version="5.4.0" />
|
||||
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview4" />
|
||||
<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="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="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="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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
82
LanMountainDesktop.AirAppHost/AirApp.axaml
Normal file
82
LanMountainDesktop.AirAppHost/AirApp.axaml
Normal file
@@ -0,0 +1,82 @@
|
||||
<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>
|
||||
24
LanMountainDesktop.AirAppHost/AirApp.axaml.cs
Normal file
24
LanMountainDesktop.AirAppHost/AirApp.axaml.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed partial class AirApp : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var options = AirAppLaunchOptions.Parse(desktop.Args ?? []);
|
||||
desktop.MainWindow = new AirAppWindow(options);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
79
LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
Normal file
79
LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed record AirAppLaunchOptions(
|
||||
string AppId,
|
||||
string SessionId,
|
||||
string? SourceComponentId,
|
||||
string? SourcePlacementId,
|
||||
string? LauncherPipeName,
|
||||
string? InstanceKey,
|
||||
string? DataRoot)
|
||||
{
|
||||
public const string WorldClockAppId = "world-clock";
|
||||
public const string WhiteboardAppId = "whiteboard";
|
||||
|
||||
public static AirAppLaunchOptions Parse(IReadOnlyList<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;
|
||||
}
|
||||
}
|
||||
16
LanMountainDesktop.AirAppHost/AirAppWindow.axaml
Normal file
16
LanMountainDesktop.AirAppHost/AirAppWindow.axaml
Normal file
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
274
LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
Normal file
274
LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Windowing;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed partial class AirAppWindow : FAAppWindow
|
||||
{
|
||||
private readonly AirAppLaunchOptions _options;
|
||||
private readonly AirAppWindowDescriptor _descriptor;
|
||||
private WhiteboardWidget? _whiteboardWidget;
|
||||
private string _instanceKey = string.Empty;
|
||||
|
||||
public AirAppWindow()
|
||||
: this(AirAppLaunchOptions.Parse([]))
|
||||
{
|
||||
}
|
||||
|
||||
public AirAppWindow(AirAppLaunchOptions options)
|
||||
{
|
||||
_options = options;
|
||||
_descriptor = AirAppWindowDescriptor.Create(options);
|
||||
InitializeComponent();
|
||||
ConfigureWindow();
|
||||
}
|
||||
|
||||
private void ConfigureWindow()
|
||||
{
|
||||
ApplyWindowDescriptor(_descriptor);
|
||||
|
||||
if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ContentHost.Content = new ClockAirAppView(_options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(_options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ConfigureWhiteboardWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
ContentHost.Content = new TextBlock
|
||||
{
|
||||
Text = $"Unsupported Air APP: {_options.AppId}",
|
||||
Margin = new Avalonia.Thickness(18)
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyWindowDescriptor(AirAppWindowDescriptor descriptor)
|
||||
{
|
||||
Title = descriptor.Title;
|
||||
Width = descriptor.Width;
|
||||
Height = descriptor.Height;
|
||||
MinWidth = descriptor.MinWidth;
|
||||
MinHeight = descriptor.MinHeight;
|
||||
ShowInTaskbar = descriptor.ShowInTaskbar;
|
||||
CanResize = descriptor.CanResize;
|
||||
ShowAsDialog = descriptor.ShowAsDialog;
|
||||
WindowState = WindowState.Normal;
|
||||
WindowRoot.Background = this.TryFindResource("AirAppWindowBackgroundBrush", out var brush) && brush is IBrush backgroundBrush
|
||||
? backgroundBrush
|
||||
: Brushes.White;
|
||||
ConfigureTitleBar(descriptor);
|
||||
|
||||
switch (descriptor.ChromeMode)
|
||||
{
|
||||
case AirAppWindowChromeMode.Standard:
|
||||
WindowDecorations = WindowDecorations.Full;
|
||||
TitleBar.ExtendsContentIntoTitleBar = false;
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.Borderless:
|
||||
WindowDecorations = WindowDecorations.None;
|
||||
TitleBar.ExtendsContentIntoTitleBar = true;
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.FullScreen:
|
||||
WindowDecorations = WindowDecorations.None;
|
||||
TitleBar.ExtendsContentIntoTitleBar = true;
|
||||
ShowAsDialog = false;
|
||||
WindowState = WindowState.FullScreen;
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.Tool:
|
||||
WindowDecorations = WindowDecorations.Full;
|
||||
TitleBar.ExtendsContentIntoTitleBar = false;
|
||||
ShowInTaskbar = false;
|
||||
CanResize = false;
|
||||
break;
|
||||
|
||||
case AirAppWindowChromeMode.BackgroundOnly:
|
||||
// Reserved for future background-only Air APPs. Keep a normal window for now
|
||||
// so accidental launches remain visible and debuggable.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureTitleBar(AirAppWindowDescriptor descriptor)
|
||||
{
|
||||
TitleBar.Height = descriptor.ChromeMode == AirAppWindowChromeMode.Tool ? 36 : 40;
|
||||
TitleBar.BackgroundColor = Colors.Transparent;
|
||||
TitleBar.ForegroundColor = Color.FromRgb(32, 32, 32);
|
||||
TitleBar.InactiveBackgroundColor = Colors.Transparent;
|
||||
TitleBar.InactiveForegroundColor = Color.FromRgb(96, 96, 96);
|
||||
TitleBar.ButtonBackgroundColor = Colors.Transparent;
|
||||
TitleBar.ButtonHoverBackgroundColor = Color.FromArgb(23, 0, 0, 0);
|
||||
TitleBar.ButtonPressedBackgroundColor = Color.FromArgb(52, 0, 0, 0);
|
||||
TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
|
||||
TitleBar.ButtonInactiveForegroundColor = Colors.Gray;
|
||||
}
|
||||
|
||||
private void ConfigureWhiteboardWindow()
|
||||
{
|
||||
var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId)
|
||||
? BuiltInComponentIds.DesktopWhiteboard
|
||||
: _options.SourceComponentId.Trim();
|
||||
var baseWidthCells = string.Equals(componentId, BuiltInComponentIds.DesktopBlackboardLandscape, StringComparison.OrdinalIgnoreCase)
|
||||
? 4
|
||||
: 2;
|
||||
var widget = new WhiteboardWidget(baseWidthCells);
|
||||
_whiteboardWidget = widget;
|
||||
widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId);
|
||||
widget.SetSurfaceMode(
|
||||
WhiteboardWidgetSurfaceMode.AirApp,
|
||||
() =>
|
||||
{
|
||||
widget.ForceSaveNote();
|
||||
Close();
|
||||
});
|
||||
|
||||
ContentHost.Content = widget;
|
||||
AppLogger.Info(
|
||||
"AirAppWindow",
|
||||
$"Whiteboard content created. ComponentId='{componentId}'; PlacementId='{_options.SourcePlacementId ?? string.Empty}'.");
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
_ = RegisterWithLauncherAsync();
|
||||
AppLogger.Info(
|
||||
"AirAppWindow",
|
||||
$"Opened. WindowRole=AirApp; AppId='{_options.AppId}'; ForegroundActivationRequested=True.");
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Activate();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
SaveWhiteboard();
|
||||
base.OnClosing(e);
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
SaveAndDisposeWhiteboard();
|
||||
_ = UnregisterWithLauncherAsync();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private void SaveAndDisposeWhiteboard()
|
||||
{
|
||||
var widget = _whiteboardWidget;
|
||||
if (widget is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveWhiteboard();
|
||||
if (ContentHost.Content == widget)
|
||||
{
|
||||
ContentHost.Content = null;
|
||||
}
|
||||
|
||||
widget.Dispose();
|
||||
_whiteboardWidget = null;
|
||||
}
|
||||
|
||||
private void SaveWhiteboard()
|
||||
{
|
||||
if (_whiteboardWidget is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_whiteboardWidget.ForceSaveNote();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppWindow", "Failed to force-save whiteboard before closing Air APP.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RegisterWithLauncherAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.LauncherPipeName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_instanceKey = ResolveInstanceKey();
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<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}";
|
||||
}
|
||||
}
|
||||
10
LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs
Normal file
10
LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public enum AirAppWindowChromeMode
|
||||
{
|
||||
Standard,
|
||||
Borderless,
|
||||
FullScreen,
|
||||
Tool,
|
||||
BackgroundOnly
|
||||
}
|
||||
151
LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
Normal file
151
LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed record AirAppWindowDescriptor(
|
||||
string WindowTitle,
|
||||
string TitleBarTitle,
|
||||
string TitleBarSubtitle,
|
||||
AirAppWindowChromeMode ChromeMode,
|
||||
bool CanResize,
|
||||
bool ShowInTaskbar,
|
||||
bool ShowAsDialog,
|
||||
double Width,
|
||||
double Height,
|
||||
double MinWidth,
|
||||
double MinHeight)
|
||||
{
|
||||
public string Title => WindowTitle;
|
||||
|
||||
public string TitleText => TitleBarTitle;
|
||||
|
||||
public string SubtitleText => TitleBarSubtitle;
|
||||
|
||||
public static AirAppWindowDescriptor Create(AirAppLaunchOptions options)
|
||||
{
|
||||
if (string.Equals(options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Standard(
|
||||
"Clock - Air APP",
|
||||
"Clock",
|
||||
"Air APP",
|
||||
width: 780,
|
||||
height: 560,
|
||||
minWidth: 680,
|
||||
minHeight: 480,
|
||||
canResize: true,
|
||||
showAsDialog: false);
|
||||
}
|
||||
|
||||
if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return FullScreen(
|
||||
"Whiteboard - Air APP",
|
||||
"Whiteboard",
|
||||
"Air APP");
|
||||
}
|
||||
|
||||
return Standard(
|
||||
"Air APP",
|
||||
"Air APP",
|
||||
options.AppId);
|
||||
}
|
||||
|
||||
public static AirAppWindowDescriptor Standard(
|
||||
string windowTitle,
|
||||
string titleBarTitle,
|
||||
string titleBarSubtitle,
|
||||
double width = 520,
|
||||
double height = 360,
|
||||
double minWidth = 360,
|
||||
double minHeight = 260,
|
||||
bool canResize = true,
|
||||
bool showAsDialog = false)
|
||||
{
|
||||
return new AirAppWindowDescriptor(
|
||||
windowTitle,
|
||||
titleBarTitle,
|
||||
titleBarSubtitle,
|
||||
AirAppWindowChromeMode.Standard,
|
||||
CanResize: canResize,
|
||||
ShowInTaskbar: true,
|
||||
ShowAsDialog: showAsDialog,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
minHeight);
|
||||
}
|
||||
|
||||
public static AirAppWindowDescriptor FullScreen(
|
||||
string windowTitle,
|
||||
string titleBarTitle,
|
||||
string titleBarSubtitle)
|
||||
{
|
||||
return new AirAppWindowDescriptor(
|
||||
windowTitle,
|
||||
titleBarTitle,
|
||||
titleBarSubtitle,
|
||||
AirAppWindowChromeMode.FullScreen,
|
||||
CanResize: false,
|
||||
ShowInTaskbar: true,
|
||||
ShowAsDialog: false,
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
MinWidth: 360,
|
||||
MinHeight: 260);
|
||||
}
|
||||
|
||||
public static AirAppWindowDescriptor Borderless(
|
||||
string windowTitle,
|
||||
double width = 520,
|
||||
double height = 360)
|
||||
{
|
||||
return new AirAppWindowDescriptor(
|
||||
windowTitle,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
AirAppWindowChromeMode.Borderless,
|
||||
CanResize: true,
|
||||
ShowInTaskbar: true,
|
||||
ShowAsDialog: false,
|
||||
width,
|
||||
height,
|
||||
MinWidth: 240,
|
||||
MinHeight: 180);
|
||||
}
|
||||
|
||||
public static AirAppWindowDescriptor Tool(
|
||||
string windowTitle,
|
||||
string titleBarTitle,
|
||||
string titleBarSubtitle,
|
||||
double width = 360,
|
||||
double height = 260)
|
||||
{
|
||||
return new AirAppWindowDescriptor(
|
||||
windowTitle,
|
||||
titleBarTitle,
|
||||
titleBarSubtitle,
|
||||
AirAppWindowChromeMode.Tool,
|
||||
CanResize: false,
|
||||
ShowInTaskbar: false,
|
||||
ShowAsDialog: true,
|
||||
width,
|
||||
height,
|
||||
MinWidth: 240,
|
||||
MinHeight: 180);
|
||||
}
|
||||
|
||||
public static AirAppWindowDescriptor BackgroundOnly(string appId)
|
||||
{
|
||||
return new AirAppWindowDescriptor(
|
||||
$"{appId} - Air APP",
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
AirAppWindowChromeMode.BackgroundOnly,
|
||||
CanResize: false,
|
||||
ShowInTaskbar: false,
|
||||
ShowAsDialog: false,
|
||||
Width: 1,
|
||||
Height: 1,
|
||||
MinWidth: 1,
|
||||
MinHeight: 1);
|
||||
}
|
||||
}
|
||||
310
LanMountainDesktop.AirAppHost/ClockAirAppView.axaml
Normal file
310
LanMountainDesktop.AirAppHost/ClockAirAppView.axaml
Normal file
@@ -0,0 +1,310 @@
|
||||
<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>
|
||||
665
LanMountainDesktop.AirAppHost/ClockAirAppView.axaml.cs
Normal file
665
LanMountainDesktop.AirAppHost/ClockAirAppView.axaml.cs
Normal file
@@ -0,0 +1,665 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.ClockAirApp;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed partial class ClockAirAppView : UserControl
|
||||
{
|
||||
private sealed class WorldClockRowVisual
|
||||
{
|
||||
public required TimeZoneInfo TimeZone { get; init; }
|
||||
|
||||
public required TextBlock TimeTextBlock { get; init; }
|
||||
|
||||
public required TextBlock DateTextBlock { get; init; }
|
||||
|
||||
public required TextBlock OffsetTextBlock { get; init; }
|
||||
}
|
||||
|
||||
private readonly DispatcherTimer _clockTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250)
|
||||
};
|
||||
|
||||
private readonly AirAppLaunchOptions _options;
|
||||
private readonly ClockAirAppSettingsStore _settingsStore = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly ClockAirAppStopwatchState _stopwatchState = new();
|
||||
private readonly ClockAirAppTimerState _timerState = new();
|
||||
private readonly List<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<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>
|
||||
53
LanMountainDesktop.AirAppHost/Program.cs
Normal file
53
LanMountainDesktop.AirAppHost/Program.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
AppDataPathProvider.Initialize(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
AppLogger.Info("AirAppHost", $"Starting. Args='{string.Join(" ", args)}'.");
|
||||
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("AirAppHost", "Exited normally.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Critical("AirAppHost", "Unhandled startup exception.", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<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();
|
||||
};
|
||||
}
|
||||
}
|
||||
39
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
Normal file
39
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
Normal file
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
52
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs
Normal file
52
LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed partial class WorldClockAirAppView : UserControl
|
||||
{
|
||||
private readonly DispatcherTimer _timer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
private readonly AirAppLaunchOptions _options;
|
||||
|
||||
public WorldClockAirAppView()
|
||||
: this(AirAppLaunchOptions.Parse([]))
|
||||
{
|
||||
}
|
||||
|
||||
public WorldClockAirAppView(AirAppLaunchOptions options)
|
||||
{
|
||||
_options = options;
|
||||
InitializeComponent();
|
||||
|
||||
SessionTextBlock.Text = string.IsNullOrWhiteSpace(_options.SourcePlacementId)
|
||||
? "World Clock"
|
||||
: $"World Clock / {_options.SourcePlacementId}";
|
||||
|
||||
_timer.Tick += OnTimerTick;
|
||||
AttachedToVisualTree += (_, _) =>
|
||||
{
|
||||
UpdateTime();
|
||||
_timer.Start();
|
||||
};
|
||||
DetachedFromVisualTree += (_, _) => _timer.Stop();
|
||||
UpdateTime();
|
||||
}
|
||||
|
||||
private void OnTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateTime();
|
||||
}
|
||||
|
||||
private void UpdateTime()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
TimeTextBlock.Text = now.ToString("HH:mm:ss", CultureInfo.CurrentCulture);
|
||||
DateTextBlock.Text = now.ToString("yyyy-MM-dd dddd", CultureInfo.CurrentCulture);
|
||||
TimeZoneTextBlock.Text = TimeZoneInfo.Local.DisplayName;
|
||||
}
|
||||
}
|
||||
95
LanMountainDesktop.AirAppRuntime/AirAppHostLocator.cs
Normal file
95
LanMountainDesktop.AirAppRuntime/AirAppHostLocator.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
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, ".."));
|
||||
}
|
||||
}
|
||||
22
LanMountainDesktop.AirAppRuntime/AirAppInstanceKey.cs
Normal file
22
LanMountainDesktop.AirAppRuntime/AirAppInstanceKey.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
330
LanMountainDesktop.AirAppRuntime/AirAppLifecycleService.cs
Normal file
330
LanMountainDesktop.AirAppRuntime/AirAppLifecycleService.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
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}");
|
||||
}
|
||||
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user